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