analytics-platform 0.2.1__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.
@@ -0,0 +1,315 @@
1
+ """Firewall module: rules from the collector (GET /events/firewall/rules,
2
+ protocol in sdk/protocols/firewall.md) every minute or so; every request is
3
+ checked against them in memory, so the app never waits on the collector.
4
+ What was blocked (or, in monitor mode, would have been) goes back in batches.
5
+
6
+ Fails open: until rules arrive, and whenever the collector can't be reached,
7
+ requests pass (with the last rules that arrived, if any). The rate limit
8
+ counts in this process: with several workers, pass `counter` (e.g. Redis)
9
+ for one count across them.
10
+ """
11
+ import bisect
12
+ import ipaddress
13
+ import json
14
+ import re
15
+ import threading
16
+ import time
17
+ from datetime import datetime, timezone
18
+
19
+ from .core import Periodic, RETRY, header
20
+ from .request_rules import compile_rules, match_request
21
+
22
+ PROTOCOL = 1
23
+ MIN_SYNC = 30
24
+ FLUSH_EVERY = 5
25
+ BATCH = 200
26
+ QUEUE_MAX = 2000
27
+ COUNTER_MAX = 100000
28
+ MAPPED = 0xFFFF << 32 # IPv4 as ::ffff:a.b.c.d, so one sorted list holds both
29
+
30
+
31
+ def ip_key(text):
32
+ """An address as an int in IPv6 space, or None when invalid."""
33
+ try:
34
+ ip = ipaddress.ip_address(str(text).strip().split("%")[0])
35
+ except ValueError:
36
+ return None
37
+ if ip.version == 4:
38
+ return MAPPED | int(ip)
39
+ return int(ip)
40
+
41
+
42
+ def parse_range(entry):
43
+ """'1.2.3.0/24' -> (start, end) as ip_key()s, or None."""
44
+ try:
45
+ net = ipaddress.ip_network(str(entry).strip(), strict=False)
46
+ except ValueError:
47
+ return None
48
+ start, end = int(net.network_address), int(net.broadcast_address)
49
+ if net.version == 4:
50
+ return MAPPED | start, MAPPED | end
51
+ return start, end
52
+
53
+
54
+ class IpSet:
55
+ """Addresses and ranges, merged and sorted, looked up by binary search."""
56
+
57
+ def __init__(self, entries=()):
58
+ ranges = sorted(r for r in (parse_range(e) for e in entries if isinstance(e, str)) if r)
59
+ self.starts, self.ends = [], []
60
+ for start, end in ranges:
61
+ if self.ends and start <= self.ends[-1] + 1:
62
+ self.ends[-1] = max(self.ends[-1], end)
63
+ else:
64
+ self.starts.append(start)
65
+ self.ends.append(end)
66
+
67
+ def has(self, key):
68
+ i = bisect.bisect_right(self.starts, key) - 1
69
+ return i >= 0 and self.ends[i] >= key
70
+
71
+ def __len__(self):
72
+ return len(self.starts)
73
+
74
+
75
+ def compile_probes(probes):
76
+ """The rules' probes (addresses of files no visitor asks for: .env,
77
+ .git/...) -> {"exact": set, "prefix": tuple, "ban": seconds}, or None."""
78
+ if not isinstance(probes, dict):
79
+ return None
80
+ def paths(key):
81
+ return [p.lower() for p in probes.get(key) or [] if isinstance(p, str) and p]
82
+ exact, prefix = set(paths("exact")), tuple(paths("prefix"))
83
+ if not exact and not prefix:
84
+ return None
85
+ try:
86
+ ban = max(0, int(probes.get("ban") or 0))
87
+ except (TypeError, ValueError):
88
+ ban = 0
89
+ return {"exact": exact, "prefix": prefix, "ban": ban}
90
+
91
+
92
+ def is_probe(path, probes):
93
+ """True when the path (no query, lower-cased, "//" as "/") is a probe."""
94
+ if not probes:
95
+ return False
96
+ clean = re.sub(r"/{2,}", "/", re.split(r"[?#]", str(path or ""), 1)[0].lower())
97
+ return clean in probes["exact"] or (bool(probes["prefix"]) and clean.startswith(probes["prefix"]))
98
+
99
+
100
+ def client_ip(remote, headers, trust_proxy):
101
+ """The visitor's address. trust_proxy: None/0 the connection's address; a
102
+ number of proxies in X-Forwarded-For (True = 1); "cloudflare" reads
103
+ CF-Connecting-IP; a function gets the headers and returns it."""
104
+ if callable(trust_proxy):
105
+ return str(trust_proxy(headers) or "")
106
+ if trust_proxy == "cloudflare":
107
+ return header(headers, "cf-connecting-ip") or remote
108
+ hops = 1 if trust_proxy is True else (trust_proxy if isinstance(trust_proxy, int) else 0)
109
+ if hops > 0:
110
+ chain = [s.strip() for s in header(headers, "x-forwarded-for").split(",") if s.strip()]
111
+ if chain:
112
+ return chain[max(0, len(chain) - hops)]
113
+ return remote
114
+
115
+
116
+ class Firewall:
117
+ """Options: trust_proxy, mode ("monitor"/"block": overrides the dashboard),
118
+ counter (callable(key, ttl) -> hits, shared between workers), cache_file
119
+ (rules kept there between restarts)."""
120
+
121
+ def __init__(self, core, trust_proxy=None, mode=None, counter=None, cache_file=None):
122
+ self.core = core
123
+ self.trust_proxy = trust_proxy
124
+ self.mode = mode if mode in ("monitor", "block") else None
125
+ self.counter = counter
126
+ self.cache_file = cache_file
127
+ self.rules = None
128
+ self.etag = ""
129
+ self.lock = threading.Lock()
130
+ self.counts = {}
131
+ self.queue = {}
132
+ # Bot protection here: address -> when its ban ends; address -> [window start, failed logins].
133
+ self.bans = {}
134
+ self.logins = {}
135
+ if cache_file:
136
+ try:
137
+ with open(cache_file) as f:
138
+ cached = json.load(f)
139
+ self.rules = self._compile(cached["rules"])
140
+ self.etag = cached.get("etag", "")
141
+ except (OSError, ValueError, KeyError, TypeError):
142
+ pass
143
+ self.jobs = []
144
+ if core.valid:
145
+ self.jobs = [Periodic(self._sync), Periodic(self._flush_job, FLUSH_EVERY)]
146
+
147
+ def _compile(self, data):
148
+ if not isinstance(data, dict) or data.get("v") != PROTOCOL:
149
+ raise ValueError("unknown rules version")
150
+ rate = data.get("rate") or None
151
+ if rate and int(rate.get("limit") or 0) > 0:
152
+ rate = {"limit": int(rate["limit"]), "window": max(1, int(rate.get("window") or 60))}
153
+ else:
154
+ rate = None
155
+ return {
156
+ "enabled": data.get("enabled") is True,
157
+ "mode": "block" if data.get("mode") == "block" else "monitor",
158
+ "sync": max(MIN_SYNC, int(data.get("sync") or RETRY)),
159
+ "allow": IpSet(data.get("allow") or []),
160
+ # Crawlers the rate limit must not catch (absent in older rules).
161
+ "goodbots": IpSet(data.get("goodbots") or []),
162
+ "deny": IpSet(data.get("deny") or []),
163
+ "list": IpSet(data.get("lists") or []),
164
+ "shared": IpSet(data.get("shared") or []),
165
+ "rate": rate,
166
+ "banned": IpSet(data.get("banned") or []),
167
+ "ban": int((data.get("ban") or {}).get("seconds") or 0),
168
+ "login": data.get("login") if isinstance(data.get("login"), dict) and int(data["login"].get("limit") or 0) > 0 else None,
169
+ "probes": compile_probes(data.get("probes")),
170
+ # Virtual patching: rules that match the request, not the address.
171
+ "requests": compile_rules(data.get("requests")),
172
+ }
173
+
174
+ def _sync(self):
175
+ headers = {"If-None-Match": self.etag} if self.etag else {}
176
+ res = self.core.send("firewall", "/events/firewall/rules?site_id=" + self.core.site_id, method="GET", auth="bearer", headers=headers)
177
+ if res is not None and res.status == 200:
178
+ data = res.json()
179
+ try:
180
+ self.rules = self._compile(data)
181
+ self.etag = res.headers.get("etag", "")
182
+ if self.cache_file:
183
+ with open(self.cache_file, "w") as f:
184
+ json.dump({"etag": self.etag, "rules": data}, f)
185
+ except (ValueError, OSError) as error:
186
+ self.core.log("firewall", "rules not used:", error)
187
+ return self.rules["sync"] if self.rules else RETRY
188
+
189
+ def _over_rate(self, ip, rate):
190
+ window = int(time.time() // rate["window"])
191
+ if self.counter:
192
+ return int(self.counter("afw:%s:%s:%s" % (self.core.site_id, ip, window), rate["window"] * 2)) > rate["limit"]
193
+ with self.lock:
194
+ entry = self.counts.get(ip)
195
+ if not entry or entry[0] != window:
196
+ if not entry and len(self.counts) >= COUNTER_MAX:
197
+ self.counts.clear()
198
+ entry = [window, 0]
199
+ self.counts[ip] = entry
200
+ entry[1] += 1
201
+ return entry[1] > rate["limit"]
202
+
203
+ def _banned_here(self, ip):
204
+ until = self.bans.get(ip)
205
+ if not until:
206
+ return False
207
+ if until > time.time():
208
+ return True
209
+ self.bans.pop(ip, None)
210
+ return False
211
+
212
+ def _ban_here(self, ip, seconds):
213
+ if seconds > 0:
214
+ with self.lock:
215
+ if len(self.bans) >= COUNTER_MAX:
216
+ self.bans.clear()
217
+ self.bans[ip] = max(self.bans.get(ip, 0), time.time() + seconds)
218
+
219
+ def login_failed(self, ip):
220
+ """A failed login on your site: counted here (past the site's limit the
221
+ address is banned straight away) and reported, so it counts on the
222
+ Security page and gets banned on the other servers too."""
223
+ key = ip_key(ip)
224
+ rules = self.rules
225
+ if key is None or (rules and rules["allow"].has(key)):
226
+ return
227
+ login = rules["login"] if rules else None
228
+ if login:
229
+ now = time.time()
230
+ with self.lock:
231
+ entry = self.logins.get(ip)
232
+ if not entry or now - entry[0] >= int(login.get("window") or 3600):
233
+ if not entry and len(self.logins) >= COUNTER_MAX:
234
+ self.logins.clear()
235
+ entry = self.logins[ip] = [now, 0]
236
+ entry[1] += 1
237
+ reached = entry[1] >= int(login["limit"])
238
+ if reached:
239
+ self.logins.pop(ip, None)
240
+ if reached:
241
+ self._ban_here(str(ip), int(login.get("ban") or 3600))
242
+ self._report({"ip": str(ip), "rule": "login", "action": "monitor", "method": "", "path": "", "user_agent": ""})
243
+
244
+ def _report(self, event):
245
+ key = "|".join((event["ip"], event["rule"], event["action"], event["method"], event["path"]))
246
+ with self.lock:
247
+ row = self.queue.get(key)
248
+ if row:
249
+ row["hits"] += 1
250
+ elif len(self.queue) < QUEUE_MAX:
251
+ self.queue[key] = dict(event, at=datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"), hits=1)
252
+
253
+ def flush(self):
254
+ with self.lock:
255
+ rows, self.queue = list(self.queue.values()), {}
256
+ for i in range(0, len(rows), BATCH):
257
+ self.core.send("firewall", "/events/firewall/events", auth="bearer",
258
+ body={"site_id": self.core.site_id, "events": rows[i:i + BATCH]})
259
+
260
+ def _flush_job(self):
261
+ self.flush()
262
+ return FLUSH_EVERY
263
+
264
+ def check(self, ip, method="", path="", user_agent=""):
265
+ """-> {"action": "allow" | "block" | "monitor", "rule": ..., "status": ...}.
266
+ Allowed addresses skip every other rule."""
267
+ allow = {"action": "allow", "rule": "", "status": 200}
268
+ rules = self.rules
269
+ if not rules or not rules["enabled"]:
270
+ return allow
271
+ key = ip_key(ip)
272
+ if key is None or rules["allow"].has(key):
273
+ return allow
274
+ action = "block" if (self.mode or rules["mode"]) == "block" else "monitor"
275
+ rule = ("deny" if rules["deny"].has(key)
276
+ else "ban" if rules["banned"].has(key) or self._banned_here(str(ip))
277
+ else "list" if rules["list"].has(key) else "shared" if rules["shared"].has(key) else "")
278
+ if not rule and is_probe(path, rules["probes"]):
279
+ rule = "probe"
280
+ # Asking for a secret file: banned here in block mode; the report bans it on the other servers.
281
+ if action == "block":
282
+ self._ban_here(str(ip), rules["probes"]["ban"])
283
+ matched = None
284
+ if not rule:
285
+ matched = match_request(rules.get("requests") or [], method, path, user_agent)
286
+ if matched:
287
+ rule = "request:" + matched["id"]
288
+ # A rule the dashboard only watches passes even in block mode;
289
+ # one that blocks also bans the address here, as a flood does.
290
+ action = "block" if action == "block" and matched["action"] == "block" else "monitor"
291
+ if action == "block" and matched["ban"]:
292
+ self._ban_here(str(ip), matched["ban"])
293
+ if not rule and rules["rate"] and not rules["goodbots"].has(key) and self._over_rate(str(ip), rules["rate"]):
294
+ rule = "rate"
295
+ # Banned here straight away; the report bans it on the site's other servers.
296
+ self._ban_here(str(ip), rules["ban"])
297
+ if not rule:
298
+ return allow
299
+ self._report({
300
+ "ip": str(ip), "rule": rule, "action": action,
301
+ "method": str(method)[:10], "path": str(path).split("?")[0][:300], "user_agent": str(user_agent)[:300],
302
+ })
303
+ return {"action": action, "rule": rule, "status": 429 if rule == "rate" else 403}
304
+
305
+ def retry_after(self):
306
+ rules = self.rules
307
+ return str(rules["rate"]["window"]) if rules and rules["rate"] else "60"
308
+
309
+ def client_ip(self, remote, headers):
310
+ return client_ip(remote, headers, self.trust_proxy)
311
+
312
+ def close(self):
313
+ for job in self.jobs:
314
+ job.stop()
315
+ self.flush()
@@ -0,0 +1,46 @@
1
+ """Feature flags module: turn features of your app on or off from the
2
+ dashboard (Site settings -> Server), for everyone, a share of users, or listed
3
+ user IDs. is_enabled() answers from memory; the flags come with the module
4
+ switches (core), about once a minute.
5
+ """
6
+
7
+
8
+ def bucket(key, user):
9
+ """FNV-1a over "key:user", 0-99: the same as the Node and PHP packages."""
10
+ h = 0x811C9DC5
11
+ for byte in ("%s:%s" % (key, user)).encode("utf-8"):
12
+ h ^= byte
13
+ h = (h * 0x01000193) & 0xFFFFFFFF
14
+ return h % 100
15
+
16
+
17
+ def evaluate(flag, key, user):
18
+ if not flag.get("on"):
19
+ return False
20
+ uid = "" if user is None else str(user)
21
+ if uid and uid in (flag.get("users") or []):
22
+ return True
23
+ rollout = int(flag.get("rollout", 100))
24
+ if rollout >= 100:
25
+ return True
26
+ if rollout <= 0 or not uid:
27
+ return False
28
+ return bucket(key, uid) < rollout
29
+
30
+
31
+ class Flags:
32
+ def __init__(self, core, **_):
33
+ self.core = core
34
+
35
+ def is_enabled(self, key, user=None, default=False):
36
+ try:
37
+ flag = self.core.flags().get(key) if self.core.is_on("flags") else None
38
+ return evaluate(flag, key, user) if isinstance(flag, dict) else default
39
+ except Exception:
40
+ return default
41
+
42
+ def flush(self):
43
+ pass
44
+
45
+ def close(self):
46
+ pass
@@ -0,0 +1,58 @@
1
+ """Form protection module: checks a form your server received before you
2
+ accept it (POST /collectanalytics/shield/verify, the same check as spam
3
+ protection's). It reads the hidden "analytics-shield" answer the tracker puts
4
+ in protected forms, and checks the email, name and text for fake sign-ups.
5
+ Spam lands in the site's spam folder (Leads -> Spam). Passwords, card numbers
6
+ and tokens are never sent. Fails open: when the check can't be made, the form
7
+ is accepted (checked False).
8
+ """
9
+ import re
10
+
11
+ PRIVATE_FIELD = re.compile(r"pass|card|cvc|cvv|secret|token|ssn|iban|csrf|analytics-shield", re.I)
12
+ MAX_FIELDS = 50
13
+ ANSWER = "analytics-shield"
14
+
15
+
16
+ def plain_fields(fields):
17
+ out = {}
18
+ for label, value in list((fields or {}).items())[:MAX_FIELDS]:
19
+ if PRIVATE_FIELD.search(str(label)):
20
+ continue
21
+ if isinstance(value, (list, tuple)):
22
+ value = ", ".join(v for v in value if isinstance(v, str))
23
+ if isinstance(value, str):
24
+ out[str(label)[:100]] = value[:2000]
25
+ return out
26
+
27
+
28
+ class Forms:
29
+ def __init__(self, core, **_):
30
+ self.core = core
31
+
32
+ def check(self, fields, ip="", email=None, name=None, form=None):
33
+ """fields: the form as a dict. -> {"ok", "spam", "reasons", "checked"}."""
34
+ unchecked = {"ok": True, "spam": False, "reasons": ["unchecked"], "checked": False}
35
+ if not self.core.is_on("forms"):
36
+ return {"ok": True, "spam": False, "reasons": [], "checked": False}
37
+ fields = dict(fields or {})
38
+ token = fields.get(ANSWER)
39
+ if isinstance(token, (list, tuple)):
40
+ token = token[0] if token else ""
41
+ body = {"token": token if isinstance(token, str) else "", "fields": plain_fields(fields), "ip": ip or ""}
42
+ for key, value in (("email", email), ("name", name), ("form", form)):
43
+ if isinstance(value, str):
44
+ body[key] = value
45
+ res = self.core.send("forms", "/collectanalytics/shield/verify", body=body)
46
+ if res is None or res.status >= 500 or res.status in (403, 429):
47
+ if res is not None and res.status == 403:
48
+ self.core.log("forms", "the site secret was refused; forms aren't checked")
49
+ return unchecked
50
+ data = res.json() or {}
51
+ spam = data.get("success") is False
52
+ return {"ok": not spam, "spam": spam, "reasons": list(data.get("reasons") or []), "checked": True}
53
+
54
+ def flush(self):
55
+ pass
56
+
57
+ def close(self):
58
+ pass
@@ -0,0 +1,72 @@
1
+ """Software inventory module (protocol in sdk/protocols/inventory.md): the
2
+ packages installed in the app's environment (importlib.metadata) and its
3
+ Python version, sent to POST /events/inventory a minute after start and then
4
+ once a day (the collector's `every`), so the Security check can match them
5
+ against known security holes. Runs in a background thread, never in a
6
+ request; names and versions only.
7
+ """
8
+ import platform
9
+ import re
10
+ import socket
11
+ from datetime import datetime, timezone
12
+
13
+ from .core import Periodic
14
+
15
+ FIRST = 60
16
+ RETRY_EVERY = 60 * 60
17
+ EVERY = 24 * 60 * 60
18
+ MAX_PACKAGES = 5000
19
+
20
+
21
+ def installed_packages():
22
+ """[{"ecosystem": "PyPI", "name", "version"}], one per distribution (by
23
+ its normalized name), as published."""
24
+ try:
25
+ from importlib import metadata
26
+ except ImportError: # Python 3.7
27
+ return []
28
+ found = {}
29
+ for dist in metadata.distributions():
30
+ try:
31
+ name = dist.metadata["Name"]
32
+ version = dist.version
33
+ except Exception:
34
+ continue
35
+ if not name or not version:
36
+ continue
37
+ key = re.sub(r"[-_.]+", "-", name).lower()
38
+ found.setdefault(key, {"ecosystem": "PyPI", "name": name, "version": str(version)})
39
+ return sorted(found.values(), key=lambda p: p["name"].lower())
40
+
41
+
42
+ class Inventory:
43
+ """Options: host (the server's name; default the host name)."""
44
+
45
+ def __init__(self, core, host=None, **_):
46
+ self.core = core
47
+ self.host = re.sub(r"[^A-Za-z0-9._-]", "-", str(host if host is not None else socket.gethostname()))[:100] or "server"
48
+ self.job = Periodic(self._report, FIRST) if core.valid else None
49
+
50
+ def _report(self):
51
+ if not self.core.is_on("inventory"):
52
+ return RETRY_EVERY
53
+ packages = installed_packages()
54
+ res = self.core.send("inventory", "/events/inventory", auth="bearer", body={
55
+ "site_id": self.core.site_id,
56
+ "host": self.host,
57
+ "at": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
58
+ "runtime": {"python": platform.python_version()},
59
+ "packages": packages[:MAX_PACKAGES],
60
+ "truncated": len(packages) > MAX_PACKAGES,
61
+ })
62
+ if res is None or not res.ok:
63
+ return RETRY_EVERY
64
+ every = (res.json() or {}).get("every")
65
+ return max(RETRY_EVERY, int(every)) if isinstance(every, int) and every > 0 else EVERY
66
+
67
+ def flush(self):
68
+ pass
69
+
70
+ def close(self):
71
+ if self.job:
72
+ self.job.stop()
@@ -0,0 +1,61 @@
1
+ """Maintenance mode module: while it's on (Site settings -> Server), visitors
2
+ get a "back soon" page (503, so search engines know it's temporary), except
3
+ allowed addresses and whoever opened the preview link (?site_preview=<code>,
4
+ remembered for a day in a cookie).
5
+ """
6
+ import html
7
+ from http.cookies import SimpleCookie
8
+ from urllib.parse import parse_qs
9
+
10
+ from .firewall import IpSet, ip_key
11
+
12
+ COOKIE = "site_preview"
13
+
14
+
15
+ def page(message):
16
+ text = html.escape(message or "We're doing some maintenance. Please check back in a few minutes.")
17
+ return ("<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">"
18
+ "<title>Back soon</title><style>body{margin:0;min-height:100vh;display:grid;place-items:center;font:16px/1.5 system-ui,sans-serif;"
19
+ "background:#f8fafc;color:#0f172a}main{max-width:32rem;padding:2rem;text-align:center}h1{font-size:1.6rem;margin:0 0 .5rem}"
20
+ "p{color:#475569;margin:0}@media(prefers-color-scheme:dark){body{background:#0f172a;color:#e2e8f0}p{color:#94a3b8}}</style></head>"
21
+ "<body><main><h1>Back soon</h1><p>%s</p></main></body></html>" % text).encode("utf-8")
22
+
23
+
24
+ class Maintenance:
25
+ """Options: skip(path) -> True lets a request through (health checks)."""
26
+
27
+ def __init__(self, core, skip=None, **_):
28
+ self.core = core
29
+ self.skip = skip
30
+ self._allow_for = None
31
+ self._allow = IpSet([])
32
+
33
+ def decide(self, ip, path, query, cookie_header):
34
+ """None to let the request through, else (status, headers, body)."""
35
+ m = self.core.maintenance() if self.core.is_on("maintenance") else None
36
+ if not m or not m.get("on") or (self.skip and self.skip(path)):
37
+ return None
38
+ if self._allow_for is not m.get("allow"):
39
+ self._allow_for = m.get("allow")
40
+ self._allow = IpSet(m.get("allow") or [])
41
+ key = ip_key(ip)
42
+ if key is not None and self._allow.has(key):
43
+ return None
44
+ code = m.get("bypass") or ""
45
+ if code:
46
+ if parse_qs(query or "").get(COOKIE, [""])[0] == code:
47
+ return ("pass", [("Set-Cookie", "%s=%s; Path=/; Max-Age=86400; HttpOnly; SameSite=Lax" % (COOKIE, code))])
48
+ cookies = SimpleCookie()
49
+ try:
50
+ cookies.load(cookie_header or "")
51
+ except Exception:
52
+ pass
53
+ if COOKIE in cookies and cookies[COOKIE].value == code:
54
+ return None
55
+ return (503, [("Content-Type", "text/html; charset=utf-8"), ("Retry-After", "600"), ("Cache-Control", "no-store")], page(m.get("message")))
56
+
57
+ def flush(self):
58
+ pass
59
+
60
+ def close(self):
61
+ pass
@@ -0,0 +1,98 @@
1
+ """Performance & API monitoring module: times every request (from arriving
2
+ to the response being ready) and sends one row per minute, method, route and
3
+ status code (how many, total and slowest time, and a count per time bucket)
4
+ to POST /events/server-requests. Nothing about visitors is sent.
5
+
6
+ The route is the framework's own pattern when the integration knows it
7
+ (Django, Flask, FastAPI); otherwise numbers and IDs in the path become ":id",
8
+ and unmatched 404s are one route.
9
+ """
10
+ import platform as host
11
+ import re
12
+ import socket
13
+ import threading
14
+ import time
15
+ from datetime import datetime, timezone
16
+
17
+ from .core import Periodic
18
+
19
+ # Keep in step with the collector (expresscollect/Analytics/serverRequests.js).
20
+ BUCKET_EDGES_MS = [5, 10, 25, 50, 100, 250, 500, 1000, 2500, 5000, 10000]
21
+ FLUSH_EVERY = 60
22
+ MAX_KEYS = 300
23
+ MAX_ROWS = 500
24
+ NOT_FOUND = "/(not found)"
25
+ _ID = re.compile(r"^(\d+|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|[0-9a-f]{16,})$", re.I)
26
+
27
+
28
+ def normalize_route(path):
29
+ """'/orders/123/items/9f8e...' -> '/orders/:id/items/:id'"""
30
+ path = re.split(r"[?#]", str(path or "/"))[0] or "/"
31
+ parts = [":id" if _ID.match(s) or (len(s) >= 20 and re.search(r"\d", s)) else s for s in path.split("/")]
32
+ route = "/".join(parts)
33
+ return (route if route.startswith("/") else "/" + route)[:200]
34
+
35
+
36
+ def bucket_of(ms):
37
+ for i, edge in enumerate(BUCKET_EDGES_MS):
38
+ if ms <= edge:
39
+ return i
40
+ return len(BUCKET_EDGES_MS)
41
+
42
+
43
+ class Performance:
44
+ """Options: server_name (default the host name)."""
45
+
46
+ def __init__(self, core, server_name=None, **_):
47
+ self.core = core
48
+ self.server_name = str(server_name or socket.gethostname())[:100]
49
+ self.runtime = "Python " + host.python_version()
50
+ self.rows = {}
51
+ self.lock = threading.Lock()
52
+ self.job = Periodic(self._flush_job, FLUSH_EVERY) if core.valid else None
53
+
54
+ def record(self, method, route, status, ms, path=""):
55
+ """One finished request. route: the framework's pattern, or "" to
56
+ work it out from path."""
57
+ if not self.core.is_on("performance"):
58
+ return
59
+ status = int(status or 0)
60
+ if not route:
61
+ route = NOT_FOUND if status == 404 else normalize_route(path)
62
+ method = str(method or "GET").upper()[:10]
63
+ minute = int(time.time() // 60) * 60
64
+ with self.lock:
65
+ key = (minute, method, route[:200], status)
66
+ if key not in self.rows and len(self.rows) >= MAX_KEYS:
67
+ key = (minute, method, "(other)", status)
68
+ row = self.rows.get(key)
69
+ if row is None:
70
+ row = self.rows[key] = {"count": 0, "sum_ms": 0.0, "max_ms": 0.0, "buckets": [0] * (len(BUCKET_EDGES_MS) + 1)}
71
+ row["count"] += 1
72
+ row["sum_ms"] += ms
73
+ row["max_ms"] = max(row["max_ms"], ms)
74
+ row["buckets"][bucket_of(ms)] += 1
75
+
76
+ def flush(self, everything=True):
77
+ current = int(time.time() // 60) * 60
78
+ with self.lock:
79
+ ready = [(k, v) for k, v in self.rows.items() if everything or k[0] < current]
80
+ for k, _ in ready:
81
+ del self.rows[k]
82
+ rows = [{
83
+ "minute": datetime.fromtimestamp(k[0], timezone.utc).isoformat().replace("+00:00", "Z"),
84
+ "method": k[1], "route": k[2], "status": k[3], "count": v["count"],
85
+ "sum_ms": round(v["sum_ms"], 1), "max_ms": round(v["max_ms"], 1), "buckets": v["buckets"],
86
+ } for k, v in ready]
87
+ for i in range(0, len(rows), MAX_ROWS):
88
+ self.core.send("performance", "/events/server-requests",
89
+ body={"server_name": self.server_name, "runtime": self.runtime, "rows": rows[i:i + MAX_ROWS]})
90
+
91
+ def _flush_job(self):
92
+ self.flush(everything=False)
93
+ return FLUSH_EVERY
94
+
95
+ def close(self):
96
+ if self.job:
97
+ self.job.stop()
98
+ self.flush()