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/__init__.py
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""Tells Slowpoke which line of your Python app ran each query.
|
|
2
|
+
|
|
3
|
+
One trace per HTTP request (Django, Flask, FastAPI/Starlette) or job, with the route template, the status
|
|
4
|
+
and every query with its file:line, sent as OTLP/JSON to the Slowpoke agent on the same machine.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import functools
|
|
8
|
+
import inspect
|
|
9
|
+
import os
|
|
10
|
+
import threading
|
|
11
|
+
|
|
12
|
+
from ._config import Config
|
|
13
|
+
from ._origin import OriginFinder
|
|
14
|
+
from ._sender import BackgroundSender, HttpSender
|
|
15
|
+
from ._tracer import VERSION, Tracer, current
|
|
16
|
+
|
|
17
|
+
__version__ = VERSION
|
|
18
|
+
__all__ = ["configure", "get_tracer", "set_defaults", "reset", "flush", "job", "command", "current"]
|
|
19
|
+
|
|
20
|
+
_lock = threading.Lock()
|
|
21
|
+
_state = {"built": False, "tracer": None, "explicit": {}, "defaults": {}}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def configure(**overrides):
|
|
25
|
+
"""Builds the tracer now from SLOWPOKE_* variables, with these keyword arguments on top: any Config
|
|
26
|
+
field (enabled, endpoint, timeout, service, max_queries, max_sql_length, backtrace_limit, code_root,
|
|
27
|
+
queue_size), plus `sender` (an object with send(bytes) -> bool) and `background` (False sends inline,
|
|
28
|
+
for tests). Returns None when Slowpoke is disabled."""
|
|
29
|
+
with _lock:
|
|
30
|
+
_state["explicit"] = dict(overrides)
|
|
31
|
+
return _build()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def get_tracer():
|
|
35
|
+
"""The tracer the integrations use, built from the environment on first use; None when disabled."""
|
|
36
|
+
if _state["built"]:
|
|
37
|
+
return _state["tracer"]
|
|
38
|
+
with _lock:
|
|
39
|
+
return _state["tracer"] if _state["built"] else _build()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def set_defaults(**defaults):
|
|
43
|
+
"""Framework defaults (code_root, service) that apply only where the environment is silent."""
|
|
44
|
+
with _lock:
|
|
45
|
+
merged = dict(_state["defaults"], **{k: v for k, v in defaults.items() if v})
|
|
46
|
+
if merged != _state["defaults"]:
|
|
47
|
+
_state["defaults"] = merged
|
|
48
|
+
_state["built"] = False
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def reset():
|
|
52
|
+
with _lock:
|
|
53
|
+
_state.update(built=False, tracer=None, explicit={}, defaults={})
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def flush(timeout=1.0):
|
|
57
|
+
"""Waits up to `timeout` seconds for queued traces to reach the agent. Never needed in a web app."""
|
|
58
|
+
tracer = get_tracer()
|
|
59
|
+
background = getattr(tracer, "background", None)
|
|
60
|
+
return background.drain(timeout) if background is not None else True
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _build():
|
|
64
|
+
_state["built"] = True
|
|
65
|
+
_state["tracer"] = None
|
|
66
|
+
try:
|
|
67
|
+
explicit = _state["explicit"]
|
|
68
|
+
defaults = _state["defaults"]
|
|
69
|
+
cfg = Config.from_env()
|
|
70
|
+
for key, value in explicit.items():
|
|
71
|
+
if hasattr(cfg, key):
|
|
72
|
+
setattr(cfg, key, value)
|
|
73
|
+
if not cfg.enabled:
|
|
74
|
+
return None
|
|
75
|
+
sender = explicit.get("sender") or HttpSender.from_url(cfg.endpoint, cfg.timeout)
|
|
76
|
+
if sender is None:
|
|
77
|
+
return None # not a local or private endpoint: nothing may be sent, so nothing is recorded
|
|
78
|
+
code_root = os.path.abspath(str(cfg.code_root or defaults.get("code_root") or os.getcwd()))
|
|
79
|
+
service = cfg.service or defaults.get("service") or os.path.basename(code_root.rstrip(os.sep)) or "app"
|
|
80
|
+
tracer = Tracer(
|
|
81
|
+
origin=OriginFinder(code_root, cfg.backtrace_limit),
|
|
82
|
+
submit=None,
|
|
83
|
+
service=service,
|
|
84
|
+
max_queries=cfg.max_queries,
|
|
85
|
+
max_sql_length=cfg.max_sql_length,
|
|
86
|
+
)
|
|
87
|
+
if explicit.get("background", True):
|
|
88
|
+
tracer.background = BackgroundSender(sender, encode=tracer.encode_json, maxsize=cfg.queue_size)
|
|
89
|
+
tracer.submit = tracer.background.submit
|
|
90
|
+
else:
|
|
91
|
+
tracer.submit = lambda trace: sender.send(tracer.encode_json(trace))
|
|
92
|
+
_state["tracer"] = tracer
|
|
93
|
+
return tracer
|
|
94
|
+
except Exception:
|
|
95
|
+
return None # a broken configuration disables Slowpoke, it never stops the app from booting
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class job:
|
|
99
|
+
"""Traces work outside HTTP requests: a script, a cron command, a custom worker loop.
|
|
100
|
+
|
|
101
|
+
with slowpoke.job("import_orders", queue="nightly"):
|
|
102
|
+
...
|
|
103
|
+
|
|
104
|
+
@slowpoke.job()
|
|
105
|
+
def send_invoices(): ...
|
|
106
|
+
|
|
107
|
+
Inside a request or another job it does nothing: the queries already belong to that trace."""
|
|
108
|
+
|
|
109
|
+
def __init__(self, name=None, queue=None):
|
|
110
|
+
self.name = name
|
|
111
|
+
self.queue = queue
|
|
112
|
+
self._tracer = None
|
|
113
|
+
self._trace = None
|
|
114
|
+
|
|
115
|
+
def _start(self, tracer):
|
|
116
|
+
return tracer.start_job(self.name or "job", self.queue)
|
|
117
|
+
|
|
118
|
+
def __enter__(self):
|
|
119
|
+
try:
|
|
120
|
+
self._tracer = get_tracer()
|
|
121
|
+
if self._tracer is not None:
|
|
122
|
+
self._trace = self._start(self._tracer)
|
|
123
|
+
except Exception:
|
|
124
|
+
self._trace = None
|
|
125
|
+
return self
|
|
126
|
+
|
|
127
|
+
def __exit__(self, exc_type, exc, tb):
|
|
128
|
+
if self._trace is not None:
|
|
129
|
+
self._tracer.finish_job(self._trace, failed=exc_type is not None)
|
|
130
|
+
self._trace = None
|
|
131
|
+
return False
|
|
132
|
+
|
|
133
|
+
async def __aenter__(self):
|
|
134
|
+
return self.__enter__()
|
|
135
|
+
|
|
136
|
+
async def __aexit__(self, exc_type, exc, tb):
|
|
137
|
+
return self.__exit__(exc_type, exc, tb)
|
|
138
|
+
|
|
139
|
+
def __call__(self, fn):
|
|
140
|
+
name = self.name or "%s.%s" % (fn.__module__, fn.__qualname__)
|
|
141
|
+
queue = self.queue
|
|
142
|
+
again = type(self)
|
|
143
|
+
|
|
144
|
+
if inspect.iscoroutinefunction(fn):
|
|
145
|
+
@functools.wraps(fn)
|
|
146
|
+
async def async_wrapper(*args, **kwargs):
|
|
147
|
+
with again(name, queue):
|
|
148
|
+
return await fn(*args, **kwargs)
|
|
149
|
+
return async_wrapper
|
|
150
|
+
|
|
151
|
+
@functools.wraps(fn)
|
|
152
|
+
def wrapper(*args, **kwargs):
|
|
153
|
+
with again(name, queue):
|
|
154
|
+
return fn(*args, **kwargs)
|
|
155
|
+
return wrapper
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
class command(job):
|
|
159
|
+
"""Traces a command run by cron, or any script nobody is waiting for.
|
|
160
|
+
|
|
161
|
+
with slowpoke.command("import_orders"):
|
|
162
|
+
...
|
|
163
|
+
|
|
164
|
+
@slowpoke.command()
|
|
165
|
+
def import_orders(): ...
|
|
166
|
+
|
|
167
|
+
Same trace as a job, filed under its own kind: on the Jobs page a nightly command and a queued
|
|
168
|
+
job are different things. Inside a request or another job it does nothing."""
|
|
169
|
+
|
|
170
|
+
def __init__(self, name=None, queue=None):
|
|
171
|
+
super().__init__(name, None) # a command has no queue: nothing dispatched it
|
|
172
|
+
|
|
173
|
+
def _start(self, tracer):
|
|
174
|
+
return tracer.start_command(self.name or "command")
|
slowpoke/_config.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import os
|
|
2
|
+
|
|
3
|
+
DEFAULT_ENDPOINT = "http://127.0.0.1:4318/v1/traces"
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def _number(raw, default, cast, minimum):
|
|
7
|
+
try:
|
|
8
|
+
value = cast(raw)
|
|
9
|
+
except (TypeError, ValueError):
|
|
10
|
+
return default
|
|
11
|
+
return value if value >= minimum else default
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Config:
|
|
15
|
+
"""Settings from SLOWPOKE_* environment variables, like the Laravel package. Nonsense values fall
|
|
16
|
+
back to the defaults: a typo in an env file must never stop the app from booting."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, enabled=True, endpoint=DEFAULT_ENDPOINT, timeout=0.1, service=None, max_queries=500,
|
|
19
|
+
max_sql_length=10000, backtrace_limit=100, code_root=None, queue_size=256):
|
|
20
|
+
self.enabled = enabled
|
|
21
|
+
self.endpoint = endpoint
|
|
22
|
+
self.timeout = timeout
|
|
23
|
+
self.service = service
|
|
24
|
+
self.max_queries = max_queries
|
|
25
|
+
self.max_sql_length = max_sql_length
|
|
26
|
+
self.backtrace_limit = backtrace_limit
|
|
27
|
+
self.code_root = code_root
|
|
28
|
+
self.queue_size = queue_size
|
|
29
|
+
|
|
30
|
+
@classmethod
|
|
31
|
+
def from_env(cls, environ=None):
|
|
32
|
+
env = os.environ if environ is None else environ
|
|
33
|
+
d = cls()
|
|
34
|
+
enabled = env.get("SLOWPOKE_ENABLED", "").strip().lower()
|
|
35
|
+
return cls(
|
|
36
|
+
enabled=enabled not in ("0", "false", "no", "off"),
|
|
37
|
+
endpoint=env.get("SLOWPOKE_OTLP_ENDPOINT", "").strip() or d.endpoint,
|
|
38
|
+
timeout=_number(env.get("SLOWPOKE_TIMEOUT"), d.timeout, float, 0.001),
|
|
39
|
+
service=env.get("SLOWPOKE_SERVICE", "").strip() or None,
|
|
40
|
+
max_queries=_number(env.get("SLOWPOKE_MAX_QUERIES"), d.max_queries, int, 0),
|
|
41
|
+
max_sql_length=_number(env.get("SLOWPOKE_MAX_SQL_LENGTH"), d.max_sql_length, int, 1),
|
|
42
|
+
backtrace_limit=_number(env.get("SLOWPOKE_BACKTRACE_LIMIT"), d.backtrace_limit, int, 1),
|
|
43
|
+
code_root=env.get("SLOWPOKE_CODE_ROOT", "").strip() or None,
|
|
44
|
+
queue_size=_number(env.get("SLOWPOKE_QUEUE_SIZE"), d.queue_size, int, 1),
|
|
45
|
+
)
|
slowpoke/_origin.py
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import sys
|
|
3
|
+
import sysconfig
|
|
4
|
+
import time
|
|
5
|
+
|
|
6
|
+
_PACKAGE_DIR = os.path.dirname(os.path.abspath(__file__)) + os.sep
|
|
7
|
+
_THIRD_PARTY_PARTS = ("/site-packages/", "/dist-packages/", "/node_modules/", "/vendor/")
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _stdlib_dirs():
|
|
11
|
+
dirs = set()
|
|
12
|
+
for key in ("stdlib", "platstdlib"):
|
|
13
|
+
try:
|
|
14
|
+
path = sysconfig.get_paths().get(key)
|
|
15
|
+
except Exception:
|
|
16
|
+
path = None
|
|
17
|
+
if path:
|
|
18
|
+
dirs.add(os.path.join(os.path.realpath(path), ""))
|
|
19
|
+
dirs.add(os.path.join(os.path.abspath(path), ""))
|
|
20
|
+
return tuple(dirs)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class OriginFinder:
|
|
24
|
+
"""Finds the application line that issued a query. OpenTelemetry's instrumentations point into
|
|
25
|
+
Django or SQLAlchemy, which tells nobody what to fix: the answer is the first frame of the app's own
|
|
26
|
+
code, outside site-packages, the standard library and this package."""
|
|
27
|
+
|
|
28
|
+
def __init__(self, code_root, limit=100, cache_size=2048):
|
|
29
|
+
self.root = os.path.join(os.path.abspath(code_root), "")
|
|
30
|
+
self.limit = max(1, int(limit))
|
|
31
|
+
self._skip = _stdlib_dirs() + (_PACKAGE_DIR,)
|
|
32
|
+
self._cache = {}
|
|
33
|
+
self._cache_size = cache_size
|
|
34
|
+
|
|
35
|
+
def find(self, task=None):
|
|
36
|
+
"""(relative file, line) of the innermost application frame, or None. `task` is the asyncio task
|
|
37
|
+
that opened the trace, for statements run on a worker thread on its behalf."""
|
|
38
|
+
frame = sys._getframe(1)
|
|
39
|
+
origin, budget = self._walk(frame, self.limit)
|
|
40
|
+
if origin is None and budget > 0:
|
|
41
|
+
# SQLAlchemy's asyncio layer runs the statement in a greenlet: the coroutine that awaited
|
|
42
|
+
# it is on the stack of the parent greenlet, suspended where it switched.
|
|
43
|
+
parent = _greenlet_parent_frame()
|
|
44
|
+
if parent is not None:
|
|
45
|
+
origin, budget = self._walk(parent, budget)
|
|
46
|
+
if origin is None and budget > 0 and task is not None:
|
|
47
|
+
# Django's async ORM and sync_to_async run the statement in a thread: the view is a
|
|
48
|
+
# coroutine suspended on "await", reachable from the task that opened the trace.
|
|
49
|
+
origin = self._from_task(task, budget)
|
|
50
|
+
return origin
|
|
51
|
+
|
|
52
|
+
def _from_task(self, task, budget):
|
|
53
|
+
try:
|
|
54
|
+
frames = []
|
|
55
|
+
awaitable = task.get_coro()
|
|
56
|
+
if getattr(awaitable, "cr_running", False):
|
|
57
|
+
# Running, not suspended: its await chain is empty and would name the outermost
|
|
58
|
+
# coroutine, a wrong answer. On the loop's own thread it cannot be suspended while we
|
|
59
|
+
# run; on a worker thread it is a few instructions away from its "await": give it at
|
|
60
|
+
# most a couple of milliseconds, then no origin rather than a misleading one.
|
|
61
|
+
asyncio = sys.modules.get("asyncio")
|
|
62
|
+
if asyncio is None or asyncio._get_running_loop() is not None:
|
|
63
|
+
return None
|
|
64
|
+
deadline = time.monotonic() + 0.002
|
|
65
|
+
while awaitable.cr_running:
|
|
66
|
+
if time.monotonic() > deadline:
|
|
67
|
+
return None
|
|
68
|
+
time.sleep(0)
|
|
69
|
+
while awaitable is not None and len(frames) < budget:
|
|
70
|
+
frame = None
|
|
71
|
+
for attr in ("cr_frame", "ag_frame", "gi_frame"):
|
|
72
|
+
frame = getattr(awaitable, attr, None)
|
|
73
|
+
if frame is not None:
|
|
74
|
+
break
|
|
75
|
+
if frame is None:
|
|
76
|
+
break
|
|
77
|
+
frames.append(frame)
|
|
78
|
+
awaitable = (getattr(awaitable, "cr_await", None) or getattr(awaitable, "ag_await", None)
|
|
79
|
+
or getattr(awaitable, "gi_yieldfrom", None))
|
|
80
|
+
for frame in reversed(frames): # innermost first
|
|
81
|
+
relative = self.classify(frame.f_code.co_filename)
|
|
82
|
+
if relative is not None:
|
|
83
|
+
return relative, frame.f_lineno
|
|
84
|
+
except Exception:
|
|
85
|
+
pass
|
|
86
|
+
return None
|
|
87
|
+
|
|
88
|
+
def from_frame(self, frame):
|
|
89
|
+
return self._walk(frame, self.limit)[0]
|
|
90
|
+
|
|
91
|
+
def _walk(self, frame, budget):
|
|
92
|
+
while frame is not None and budget > 0:
|
|
93
|
+
budget -= 1
|
|
94
|
+
code = frame.f_code
|
|
95
|
+
relative = self.classify(code.co_filename)
|
|
96
|
+
if relative is not None:
|
|
97
|
+
return (relative, frame.f_lineno), budget
|
|
98
|
+
frame = frame.f_back
|
|
99
|
+
return None, budget
|
|
100
|
+
|
|
101
|
+
def classify(self, filename):
|
|
102
|
+
"""The path to report for a file, or None for code that is not the application's. Cached per
|
|
103
|
+
file name: a request runs hundreds of queries through the same few dozen files."""
|
|
104
|
+
try:
|
|
105
|
+
return self._cache[filename]
|
|
106
|
+
except KeyError:
|
|
107
|
+
pass
|
|
108
|
+
result = self._classify(filename)
|
|
109
|
+
if len(self._cache) >= self._cache_size:
|
|
110
|
+
self._cache.clear()
|
|
111
|
+
self._cache[filename] = result
|
|
112
|
+
return result
|
|
113
|
+
|
|
114
|
+
def _classify(self, filename):
|
|
115
|
+
if not filename or filename.startswith("<"):
|
|
116
|
+
return None # <frozen ...>, <string>, <stdin>
|
|
117
|
+
path = filename.replace("\\", "/")
|
|
118
|
+
if any(part in path for part in _THIRD_PARTY_PARTS) or path.startswith(("vendor/", "node_modules/")):
|
|
119
|
+
return None
|
|
120
|
+
absolute = os.path.abspath(filename)
|
|
121
|
+
if absolute.startswith(self._skip):
|
|
122
|
+
return None
|
|
123
|
+
if absolute.startswith(self.root):
|
|
124
|
+
return absolute[len(self.root):].replace("\\", "/")
|
|
125
|
+
return path
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _greenlet_parent_frame():
|
|
129
|
+
greenlet = sys.modules.get("greenlet")
|
|
130
|
+
if greenlet is None:
|
|
131
|
+
return None
|
|
132
|
+
try:
|
|
133
|
+
parent = greenlet.getcurrent().parent
|
|
134
|
+
return parent.gr_frame if parent is not None else None
|
|
135
|
+
except Exception:
|
|
136
|
+
return None
|
slowpoke/_sender.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import atexit
|
|
2
|
+
import http.client
|
|
3
|
+
import ipaddress
|
|
4
|
+
import os
|
|
5
|
+
import queue
|
|
6
|
+
import threading
|
|
7
|
+
import time
|
|
8
|
+
import weakref
|
|
9
|
+
from urllib.parse import urlsplit
|
|
10
|
+
|
|
11
|
+
_PRIVATE_SUFFIXES = (".localhost", ".local", ".internal", ".lan", ".home.arpa")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _private_host(host):
|
|
15
|
+
try:
|
|
16
|
+
ip = ipaddress.ip_address(host)
|
|
17
|
+
except ValueError:
|
|
18
|
+
host = host.lower()
|
|
19
|
+
# single-label names are Docker services or /etc/hosts entries
|
|
20
|
+
return host == "localhost" or "." not in host or host.endswith(_PRIVATE_SUFFIXES)
|
|
21
|
+
return ip.is_private or ip.is_loopback or ip.is_link_local
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class HttpSender:
|
|
25
|
+
"""Posts OTLP/JSON to the Slowpoke agent on this machine or the private network, with a hard time
|
|
26
|
+
budget shared by connect, write and read. Only the background worker calls it."""
|
|
27
|
+
|
|
28
|
+
def __init__(self, host, port, path, timeout):
|
|
29
|
+
self.host = host
|
|
30
|
+
self.port = port
|
|
31
|
+
self.path = path
|
|
32
|
+
self.timeout = timeout
|
|
33
|
+
|
|
34
|
+
@classmethod
|
|
35
|
+
def from_url(cls, url, timeout):
|
|
36
|
+
"""None unless the URL is plain http to a local or private host: queries must not travel the internet."""
|
|
37
|
+
try:
|
|
38
|
+
parts = urlsplit(url or "")
|
|
39
|
+
if parts.scheme != "http" or not parts.hostname or not _private_host(parts.hostname):
|
|
40
|
+
return None
|
|
41
|
+
port = parts.port or 80
|
|
42
|
+
except ValueError:
|
|
43
|
+
return None
|
|
44
|
+
path = parts.path if parts.path not in ("", "/") else "/v1/traces"
|
|
45
|
+
return cls(parts.hostname, port, path, max(0.001, float(timeout)))
|
|
46
|
+
|
|
47
|
+
def send(self, body):
|
|
48
|
+
deadline = time.monotonic() + self.timeout
|
|
49
|
+
conn = http.client.HTTPConnection(self.host, self.port, timeout=self.timeout)
|
|
50
|
+
try:
|
|
51
|
+
conn.connect()
|
|
52
|
+
conn.sock.settimeout(max(0.001, deadline - time.monotonic()))
|
|
53
|
+
conn.request("POST", self.path, body, {
|
|
54
|
+
"Content-Type": "application/json",
|
|
55
|
+
"Connection": "close",
|
|
56
|
+
"User-Agent": "slowpoke-python",
|
|
57
|
+
})
|
|
58
|
+
left = deadline - time.monotonic()
|
|
59
|
+
if left <= 0:
|
|
60
|
+
return False
|
|
61
|
+
conn.sock.settimeout(left)
|
|
62
|
+
response = conn.getresponse()
|
|
63
|
+
return 200 <= response.status < 300
|
|
64
|
+
except Exception:
|
|
65
|
+
return False
|
|
66
|
+
finally:
|
|
67
|
+
try:
|
|
68
|
+
conn.close()
|
|
69
|
+
except Exception:
|
|
70
|
+
pass
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
_instances = weakref.WeakSet()
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class BackgroundSender:
|
|
77
|
+
"""A bounded queue and one daemon thread: the request only pays for put_nowait. A full queue means
|
|
78
|
+
the agent is slow or gone, and the trace is dropped instead of piling up in the app's memory."""
|
|
79
|
+
|
|
80
|
+
def __init__(self, sender, encode, maxsize=256):
|
|
81
|
+
self.sender = sender
|
|
82
|
+
self.encode = encode
|
|
83
|
+
self.maxsize = maxsize
|
|
84
|
+
self._lock = threading.Lock()
|
|
85
|
+
self._after_fork()
|
|
86
|
+
_instances.add(self)
|
|
87
|
+
|
|
88
|
+
def _after_fork(self):
|
|
89
|
+
# A forked worker (gunicorn --preload, Celery prefork) inherits the object but not the thread.
|
|
90
|
+
self._queue = queue.Queue(self.maxsize)
|
|
91
|
+
self._thread = None
|
|
92
|
+
self._pid = os.getpid()
|
|
93
|
+
|
|
94
|
+
def submit(self, item):
|
|
95
|
+
try:
|
|
96
|
+
if self._thread is None or self._pid != os.getpid():
|
|
97
|
+
self._start()
|
|
98
|
+
self._queue.put_nowait(item)
|
|
99
|
+
return True
|
|
100
|
+
except Exception:
|
|
101
|
+
return False
|
|
102
|
+
|
|
103
|
+
def _start(self):
|
|
104
|
+
with self._lock:
|
|
105
|
+
if self._pid != os.getpid():
|
|
106
|
+
self._after_fork()
|
|
107
|
+
if self._thread is None:
|
|
108
|
+
thread = threading.Thread(target=self._run, args=(self._queue,), name="slowpoke-sender", daemon=True)
|
|
109
|
+
thread.start()
|
|
110
|
+
self._thread = thread
|
|
111
|
+
|
|
112
|
+
def _run(self, q):
|
|
113
|
+
while True:
|
|
114
|
+
item = q.get()
|
|
115
|
+
try:
|
|
116
|
+
self.sender.send(self.encode(item))
|
|
117
|
+
except Exception:
|
|
118
|
+
pass # the agent is optional: a broken one costs a trace, nothing else
|
|
119
|
+
finally:
|
|
120
|
+
q.task_done()
|
|
121
|
+
|
|
122
|
+
def drain(self, timeout):
|
|
123
|
+
"""Waits until every queued trace was handed to the agent (or given up). For tests and exit."""
|
|
124
|
+
deadline = time.monotonic() + timeout
|
|
125
|
+
q = self._queue
|
|
126
|
+
with q.all_tasks_done:
|
|
127
|
+
while q.unfinished_tasks:
|
|
128
|
+
left = deadline - time.monotonic()
|
|
129
|
+
if left <= 0:
|
|
130
|
+
return False
|
|
131
|
+
q.all_tasks_done.wait(left)
|
|
132
|
+
return True
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@atexit.register
|
|
136
|
+
def _drain_at_exit():
|
|
137
|
+
# Scripts and short jobs end right after their last trace: give it a moment, never more.
|
|
138
|
+
deadline = time.monotonic() + 0.5
|
|
139
|
+
for instance in list(_instances):
|
|
140
|
+
if instance._thread is not None and instance._pid == os.getpid():
|
|
141
|
+
instance.drain(max(0.0, deadline - time.monotonic()))
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
if hasattr(os, "register_at_fork"):
|
|
145
|
+
os.register_at_fork(after_in_child=lambda: [i._after_fork() for i in list(_instances)])
|