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.
- analytics_platform/__init__.py +483 -0
- analytics_platform/analytics.py +101 -0
- analytics_platform/audit.py +53 -0
- analytics_platform/core.py +199 -0
- analytics_platform/crawlers.py +191 -0
- analytics_platform/django.py +112 -0
- analytics_platform/errors.py +113 -0
- analytics_platform/firewall.py +315 -0
- analytics_platform/flags.py +46 -0
- analytics_platform/forms.py +58 -0
- analytics_platform/inventory.py +72 -0
- analytics_platform/maintenance.py +61 -0
- analytics_platform/performance.py +98 -0
- analytics_platform/posture.py +264 -0
- analytics_platform/proxy.py +170 -0
- analytics_platform/request_rules.py +175 -0
- analytics_platform/resources.py +471 -0
- analytics_platform-0.2.1.dist-info/METADATA +227 -0
- analytics_platform-0.2.1.dist-info/RECORD +22 -0
- analytics_platform-0.2.1.dist-info/WHEEL +5 -0
- analytics_platform-0.2.1.dist-info/licenses/LICENSE +21 -0
- analytics_platform-0.2.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,483 @@
|
|
|
1
|
+
"""One package for a site's Python server, run from the analytics dashboard:
|
|
2
|
+
firewall, error monitoring and server analytics. Each module is separate:
|
|
3
|
+
only the ones turned on are loaded, and one failing never stops the others;
|
|
4
|
+
the firewall in particular never waits on reporting. A loaded module can
|
|
5
|
+
also be switched off in the dashboard (Site settings -> Server).
|
|
6
|
+
|
|
7
|
+
platform = create_platform(site_id=..., secret=..., endpoint=...)
|
|
8
|
+
|
|
9
|
+
# FastAPI / Starlette # Flask # Django: see analytics_platform.django
|
|
10
|
+
app.add_middleware( platform.init_flask(app)
|
|
11
|
+
PlatformMiddleware, platform=platform)
|
|
12
|
+
|
|
13
|
+
A module is on with True (or its options as a dict) and off with False.
|
|
14
|
+
No dependencies; Python 3.8+.
|
|
15
|
+
"""
|
|
16
|
+
import asyncio
|
|
17
|
+
import time
|
|
18
|
+
|
|
19
|
+
from .core import Core, SESSION_HEADER, VERSION, header
|
|
20
|
+
|
|
21
|
+
__all__ = ["create_platform", "Platform", "PlatformMiddleware", "VERSION"]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _load(name, core, opts):
|
|
25
|
+
if name == "firewall":
|
|
26
|
+
from .firewall import Firewall
|
|
27
|
+
return Firewall(core, **opts)
|
|
28
|
+
if name == "errors":
|
|
29
|
+
from .errors import Errors
|
|
30
|
+
return Errors(core, **opts)
|
|
31
|
+
if name == "analytics":
|
|
32
|
+
from .analytics import Analytics
|
|
33
|
+
return Analytics(core, **opts)
|
|
34
|
+
if name == "performance":
|
|
35
|
+
from .performance import Performance
|
|
36
|
+
return Performance(core, **opts)
|
|
37
|
+
if name == "crawlers":
|
|
38
|
+
from .crawlers import Crawlers
|
|
39
|
+
return Crawlers(core, **opts)
|
|
40
|
+
if name == "forms":
|
|
41
|
+
from .forms import Forms
|
|
42
|
+
return Forms(core, **opts)
|
|
43
|
+
if name == "headers":
|
|
44
|
+
return _Headers()
|
|
45
|
+
if name == "audit":
|
|
46
|
+
from .audit import Audit
|
|
47
|
+
return Audit(core, **opts)
|
|
48
|
+
if name == "flags":
|
|
49
|
+
from .flags import Flags
|
|
50
|
+
return Flags(core, **opts)
|
|
51
|
+
if name == "maintenance":
|
|
52
|
+
from .maintenance import Maintenance
|
|
53
|
+
return Maintenance(core, **opts)
|
|
54
|
+
if name == "inventory":
|
|
55
|
+
from .inventory import Inventory
|
|
56
|
+
return Inventory(core, **opts)
|
|
57
|
+
if name == "posture":
|
|
58
|
+
from .posture import Posture
|
|
59
|
+
return Posture(core, **opts)
|
|
60
|
+
if name == "resources":
|
|
61
|
+
from .resources import Resources
|
|
62
|
+
return Resources(core, **opts)
|
|
63
|
+
if name == "proxy":
|
|
64
|
+
from .proxy import Proxy
|
|
65
|
+
return Proxy(core, **opts)
|
|
66
|
+
raise ValueError(name)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
MODULES = ("proxy", "firewall", "errors", "analytics", "performance", "crawlers", "forms", "headers", "audit", "flags", "maintenance", "inventory", "posture", "resources")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class Platform:
|
|
73
|
+
"""site_id, secret (site settings; keep it on the server), endpoint (the
|
|
74
|
+
collector's URL); trust_proxy (how to find the visitor's address: None
|
|
75
|
+
uses the connection's, a number of proxies in X-Forwarded-For,
|
|
76
|
+
"cloudflare"); debug (print send problems); per module True / False / {options}."""
|
|
77
|
+
|
|
78
|
+
def __init__(self, site_id, secret, endpoint, trust_proxy=None, debug=False, firewall=True, errors=True, analytics=True,
|
|
79
|
+
performance=True, crawlers=True, forms=True, headers=True, audit=True, flags=True, maintenance=True,
|
|
80
|
+
inventory=True,
|
|
81
|
+
posture=True, resources=True, proxy=True):
|
|
82
|
+
self.core = Core(site_id, secret, endpoint, debug)
|
|
83
|
+
if not self.core.valid:
|
|
84
|
+
print("[analytics-platform] site_id, secret and endpoint are required; nothing is sent and every request passes")
|
|
85
|
+
settings = {"firewall": firewall, "errors": errors, "analytics": analytics, "performance": performance,
|
|
86
|
+
"crawlers": crawlers, "forms": forms,
|
|
87
|
+
"headers": headers, "audit": audit, "flags": flags, "maintenance": maintenance, "inventory": inventory,
|
|
88
|
+
"posture": posture, "resources": resources, "proxy": proxy}
|
|
89
|
+
self._modules = {}
|
|
90
|
+
for name in MODULES:
|
|
91
|
+
setting = settings[name]
|
|
92
|
+
if setting is False or setting is None:
|
|
93
|
+
continue
|
|
94
|
+
opts = dict(setting) if isinstance(setting, dict) else {}
|
|
95
|
+
if name == "firewall":
|
|
96
|
+
opts.setdefault("trust_proxy", trust_proxy)
|
|
97
|
+
try:
|
|
98
|
+
self._modules[name] = _load(name, self.core, opts)
|
|
99
|
+
except Exception as error:
|
|
100
|
+
print("[analytics-platform] the %s module could not start and is off: %s" % (name, error))
|
|
101
|
+
self.core.start(list(self._modules))
|
|
102
|
+
|
|
103
|
+
# ---- Modules ----
|
|
104
|
+
|
|
105
|
+
@property
|
|
106
|
+
def modules(self):
|
|
107
|
+
"""The modules that are on."""
|
|
108
|
+
return list(self._modules)
|
|
109
|
+
|
|
110
|
+
@property
|
|
111
|
+
def firewall(self):
|
|
112
|
+
return self._modules.get("firewall")
|
|
113
|
+
|
|
114
|
+
@property
|
|
115
|
+
def proxy(self):
|
|
116
|
+
"""The first-party proxy: proxy.script_url() and proxy.collect_url()
|
|
117
|
+
are the two addresses the page's script tag needs, on this site's own
|
|
118
|
+
domain, so ad blockers have nothing of ours to recognise."""
|
|
119
|
+
return self._modules.get("proxy")
|
|
120
|
+
|
|
121
|
+
def proxy_response(self, path, method="GET", query="", headers=None, body=None, remote=""):
|
|
122
|
+
"""(status, [(name, value)], body) when the address belongs to the
|
|
123
|
+
proxy, else None. Called first by every integration: a visitor's
|
|
124
|
+
beacon is our traffic, not a page of the site, so it is answered
|
|
125
|
+
before the firewall, the crawler count and the timing."""
|
|
126
|
+
proxy = self._modules.get("proxy")
|
|
127
|
+
if proxy is None:
|
|
128
|
+
return None
|
|
129
|
+
found = proxy.match(path)
|
|
130
|
+
if found is None:
|
|
131
|
+
return None
|
|
132
|
+
kind, rest = found
|
|
133
|
+
headers = headers or {}
|
|
134
|
+
firewall = self._modules.get("firewall")
|
|
135
|
+
ip = firewall.client_ip(remote, headers) if firewall else remote
|
|
136
|
+
try:
|
|
137
|
+
return proxy.handle(kind, rest, method=method, query=query, headers=headers, body=body, client_ip=ip)
|
|
138
|
+
except Exception as error:
|
|
139
|
+
self.core.log("proxy", "failed:", error)
|
|
140
|
+
return 502, [], b""
|
|
141
|
+
|
|
142
|
+
def capture_exception(self, error, request=None, status=0, kind="handled"):
|
|
143
|
+
errors = self._modules.get("errors")
|
|
144
|
+
if errors:
|
|
145
|
+
errors.capture_exception(error, request=request, status=status, kind=kind)
|
|
146
|
+
|
|
147
|
+
def login_failed(self, request_or_ip):
|
|
148
|
+
"""A failed login (bot protection): the address, or a Django, Flask or
|
|
149
|
+
Starlette request (its address as trust_proxy says)."""
|
|
150
|
+
firewall = self._modules.get("firewall")
|
|
151
|
+
if not firewall:
|
|
152
|
+
return
|
|
153
|
+
ip = request_or_ip if isinstance(request_or_ip, str) else self.request_ip(request_or_ip)
|
|
154
|
+
if ip:
|
|
155
|
+
firewall.login_failed(ip)
|
|
156
|
+
|
|
157
|
+
def request_ip(self, request):
|
|
158
|
+
"""The visitor's address for a Django, Flask or Starlette request."""
|
|
159
|
+
firewall = self._modules.get("firewall")
|
|
160
|
+
headers = getattr(request, "headers", None) or {}
|
|
161
|
+
lowered = {str(k).lower(): v for k, v in headers.items()} if hasattr(headers, "items") else {}
|
|
162
|
+
meta = getattr(request, "META", None)
|
|
163
|
+
if meta is not None:
|
|
164
|
+
remote = meta.get("REMOTE_ADDR", "")
|
|
165
|
+
elif getattr(request, "client", None) is not None:
|
|
166
|
+
remote = request.client.host or ""
|
|
167
|
+
else:
|
|
168
|
+
remote = getattr(request, "remote_addr", "") or ""
|
|
169
|
+
return firewall.client_ip(remote, lowered) if firewall else remote
|
|
170
|
+
|
|
171
|
+
def is_enabled(self, key, user=None, default=False):
|
|
172
|
+
"""A feature flag from the dashboard (user: for rollouts and user lists;
|
|
173
|
+
default: until the flag is known, or with the module off)."""
|
|
174
|
+
flags = self._modules.get("flags")
|
|
175
|
+
return flags.is_enabled(key, user, default) if flags else default
|
|
176
|
+
|
|
177
|
+
def maintenance_response(self, remote, headers, path, query):
|
|
178
|
+
"""Maintenance mode: None to let the request through, ("pass", extra
|
|
179
|
+
headers) to let it through and remember the preview link, or (status,
|
|
180
|
+
headers, body) for the "back soon" page."""
|
|
181
|
+
maintenance = self._modules.get("maintenance")
|
|
182
|
+
if not maintenance:
|
|
183
|
+
return None
|
|
184
|
+
firewall = self._modules.get("firewall")
|
|
185
|
+
try:
|
|
186
|
+
ip = firewall.client_ip(remote, headers) if firewall else remote
|
|
187
|
+
return maintenance.decide(ip, path, query, header(headers, "cookie"))
|
|
188
|
+
except Exception as error:
|
|
189
|
+
self.core.log("maintenance", "check failed, request passed:", error)
|
|
190
|
+
return None
|
|
191
|
+
|
|
192
|
+
def audit(self, action, actor="", target="", details=None, request=None):
|
|
193
|
+
"""Records who did what (Security -> Audit log). action: e.g.
|
|
194
|
+
"user.role_changed"; request adds the actor's address and visit."""
|
|
195
|
+
audit = self._modules.get("audit")
|
|
196
|
+
if not audit:
|
|
197
|
+
return
|
|
198
|
+
try:
|
|
199
|
+
ip = self.request_ip(request) if request is not None else ""
|
|
200
|
+
audit.record(action, actor, target, details, ip, self.session_id(request) if request is not None else "")
|
|
201
|
+
except Exception as error:
|
|
202
|
+
self.core.log("audit", "could not record:", error)
|
|
203
|
+
|
|
204
|
+
def check_form(self, request=None, fields=None, email=None, name=None, form=None):
|
|
205
|
+
"""Form protection: checks a form before you accept it. The form is
|
|
206
|
+
request.POST (Django) or request.form (Flask), else pass fields (FastAPI:
|
|
207
|
+
fields=dict(await request.form())). -> {"ok", "spam", "reasons", "checked"};
|
|
208
|
+
spam goes to Leads -> Spam."""
|
|
209
|
+
forms = self._modules.get("forms")
|
|
210
|
+
if not forms:
|
|
211
|
+
return {"ok": True, "spam": False, "reasons": [], "checked": False}
|
|
212
|
+
if fields is None and request is not None:
|
|
213
|
+
source = getattr(request, "POST", None)
|
|
214
|
+
if source is None:
|
|
215
|
+
source = getattr(request, "form", None)
|
|
216
|
+
if source is not None:
|
|
217
|
+
fields = {k: (source.getlist(k) if hasattr(source, "getlist") and len(source.getlist(k)) > 1 else source.get(k)) for k in source.keys()}
|
|
218
|
+
try:
|
|
219
|
+
return forms.check(fields or {}, self.request_ip(request) if request is not None else "", email, name, form)
|
|
220
|
+
except Exception as error:
|
|
221
|
+
self.core.log("forms", "could not check a form:", error)
|
|
222
|
+
return {"ok": True, "spam": False, "reasons": ["unchecked"], "checked": False}
|
|
223
|
+
|
|
224
|
+
def track(self, name, fields=None, session_id=""):
|
|
225
|
+
analytics = self._modules.get("analytics")
|
|
226
|
+
return analytics.track(name, fields, session_id) if analytics else ""
|
|
227
|
+
|
|
228
|
+
def identify(self, user_id, session_id=""):
|
|
229
|
+
analytics = self._modules.get("analytics")
|
|
230
|
+
return analytics.identify(user_id, session_id) if analytics else False
|
|
231
|
+
|
|
232
|
+
def lead(self, fields, session_id=""):
|
|
233
|
+
analytics = self._modules.get("analytics")
|
|
234
|
+
return analytics.lead(fields, session_id) if analytics else None
|
|
235
|
+
|
|
236
|
+
def security_headers(self, present=()):
|
|
237
|
+
"""The dashboard's security headers the response doesn't set itself
|
|
238
|
+
(present: the header names it has)."""
|
|
239
|
+
if "headers" not in self._modules:
|
|
240
|
+
return []
|
|
241
|
+
have = {str(name).lower() for name in present}
|
|
242
|
+
return [(n, v) for n, v in self.core.security_headers() if n.lower() not in have]
|
|
243
|
+
|
|
244
|
+
def time_request(self, method, route, status, started, path=""):
|
|
245
|
+
"""Records a finished request for Server speed (started: time.perf_counter())."""
|
|
246
|
+
performance = self._modules.get("performance")
|
|
247
|
+
if performance:
|
|
248
|
+
try:
|
|
249
|
+
performance.record(method, route, status, (time.perf_counter() - started) * 1000, path)
|
|
250
|
+
except Exception as error:
|
|
251
|
+
self.core.log("performance", "could not time a request:", error)
|
|
252
|
+
|
|
253
|
+
def record_crawler(self, user_agent, path, status, started=None, size=0, ip=""):
|
|
254
|
+
"""Records a finished request when it came from a crawler or AI bot
|
|
255
|
+
(Health → Crawlers). started: time.perf_counter() when it arrived."""
|
|
256
|
+
crawlers = self._modules.get("crawlers")
|
|
257
|
+
if crawlers:
|
|
258
|
+
try:
|
|
259
|
+
ms = 0 if started is None else (time.perf_counter() - started) * 1000
|
|
260
|
+
crawlers.middleware(user_agent, path, status, ms, size, ip)
|
|
261
|
+
except Exception as error:
|
|
262
|
+
self.core.log("crawlers", "could not count a crawler request:", error)
|
|
263
|
+
|
|
264
|
+
@staticmethod
|
|
265
|
+
def session_id(request):
|
|
266
|
+
"""The browser's visit, from a Django, Flask or Starlette request."""
|
|
267
|
+
headers = getattr(request, "headers", None)
|
|
268
|
+
try:
|
|
269
|
+
value = headers.get(SESSION_HEADER) if headers is not None else ""
|
|
270
|
+
except Exception:
|
|
271
|
+
value = ""
|
|
272
|
+
return value if isinstance(value, str) else ""
|
|
273
|
+
|
|
274
|
+
def flush(self):
|
|
275
|
+
"""Sends everything waiting (serverless: before returning)."""
|
|
276
|
+
for module in self._modules.values():
|
|
277
|
+
module.flush()
|
|
278
|
+
|
|
279
|
+
def close(self):
|
|
280
|
+
"""Stops background work and sends what's waiting."""
|
|
281
|
+
self.core.stop()
|
|
282
|
+
for module in self._modules.values():
|
|
283
|
+
module.close()
|
|
284
|
+
|
|
285
|
+
# ---- Requests ----
|
|
286
|
+
|
|
287
|
+
def blocked(self, remote, headers, method, path):
|
|
288
|
+
"""(status, body, headers) when the firewall turns the request away,
|
|
289
|
+
else None. headers: a dict with lowercase names."""
|
|
290
|
+
firewall = self._modules.get("firewall")
|
|
291
|
+
if not firewall:
|
|
292
|
+
return None
|
|
293
|
+
try:
|
|
294
|
+
decision = firewall.check(firewall.client_ip(remote, headers), method, path, header(headers, "user-agent"))
|
|
295
|
+
except Exception as error:
|
|
296
|
+
self.core.log("firewall", "check failed, request passed:", error)
|
|
297
|
+
return None
|
|
298
|
+
if decision["action"] != "block":
|
|
299
|
+
return None
|
|
300
|
+
extra = [("Content-Type", "text/plain; charset=utf-8"), ("Cache-Control", "no-store")]
|
|
301
|
+
if decision["status"] == 429:
|
|
302
|
+
extra.append(("Retry-After", firewall.retry_after()))
|
|
303
|
+
return 429, b"Too Many Requests", extra
|
|
304
|
+
return 403, b"Forbidden", extra
|
|
305
|
+
|
|
306
|
+
def wsgi(self, app, timing=True):
|
|
307
|
+
"""WSGI middleware (Flask, Bottle, Pyramid, plain WSGI). timing=False
|
|
308
|
+
leaves timing to the framework integration (Flask, which knows the route)."""
|
|
309
|
+
platform = self
|
|
310
|
+
|
|
311
|
+
def middleware(environ, start_response):
|
|
312
|
+
started = time.perf_counter()
|
|
313
|
+
headers = {k[5:].replace("_", "-").lower(): v for k, v in environ.items() if k.startswith("HTTP_")}
|
|
314
|
+
if environ.get("CONTENT_TYPE"):
|
|
315
|
+
headers["content-type"] = environ["CONTENT_TYPE"]
|
|
316
|
+
answered = platform.proxy_response(
|
|
317
|
+
environ.get("PATH_INFO", ""), environ.get("REQUEST_METHOD", ""), environ.get("QUERY_STRING", ""),
|
|
318
|
+
headers, _wsgi_body(environ), environ.get("REMOTE_ADDR", ""))
|
|
319
|
+
if answered:
|
|
320
|
+
status, extra, body = answered
|
|
321
|
+
start_response("%d " % status, list(extra))
|
|
322
|
+
return [body]
|
|
323
|
+
blocked = platform.blocked(environ.get("REMOTE_ADDR", ""), headers, environ.get("REQUEST_METHOD", ""),
|
|
324
|
+
environ.get("PATH_INFO", ""))
|
|
325
|
+
if blocked:
|
|
326
|
+
status, body, extra = blocked
|
|
327
|
+
start_response("%d %s" % (status, body.decode()), extra)
|
|
328
|
+
return [body]
|
|
329
|
+
closed = platform.maintenance_response(environ.get("REMOTE_ADDR", ""), headers, environ.get("PATH_INFO", ""), environ.get("QUERY_STRING", ""))
|
|
330
|
+
if closed and closed[0] != "pass":
|
|
331
|
+
start_response("503 Service Unavailable", closed[1])
|
|
332
|
+
return [closed[2]]
|
|
333
|
+
remember = closed[1] if closed else []
|
|
334
|
+
seen = {"status": 500}
|
|
335
|
+
|
|
336
|
+
def timed_start_response(status, response_headers, exc_info=None):
|
|
337
|
+
try:
|
|
338
|
+
seen["status"] = int(str(status).split(" ", 1)[0])
|
|
339
|
+
except ValueError:
|
|
340
|
+
pass
|
|
341
|
+
response_headers = list(response_headers) + platform.security_headers(n for n, _ in response_headers) + remember
|
|
342
|
+
return start_response(status, response_headers, exc_info) if exc_info else start_response(status, response_headers)
|
|
343
|
+
|
|
344
|
+
try:
|
|
345
|
+
return app(environ, timed_start_response)
|
|
346
|
+
except Exception as error:
|
|
347
|
+
platform.capture_exception(error, status=500, kind="error", request={
|
|
348
|
+
"method": environ.get("REQUEST_METHOD", ""), "path": environ.get("PATH_INFO", ""),
|
|
349
|
+
"session_id": headers.get(SESSION_HEADER, ""),
|
|
350
|
+
})
|
|
351
|
+
raise
|
|
352
|
+
finally:
|
|
353
|
+
if timing:
|
|
354
|
+
platform.time_request(environ.get("REQUEST_METHOD", ""), "", seen["status"], started, environ.get("PATH_INFO", ""))
|
|
355
|
+
|
|
356
|
+
return middleware
|
|
357
|
+
|
|
358
|
+
def init_flask(self, app):
|
|
359
|
+
"""Flask: the firewall in front of the app, and its request errors."""
|
|
360
|
+
app.wsgi_app = self.wsgi(app.wsgi_app, timing=False)
|
|
361
|
+
from flask import g, got_request_exception, request
|
|
362
|
+
|
|
363
|
+
@app.before_request
|
|
364
|
+
def _analytics_started():
|
|
365
|
+
g._analytics_started = time.perf_counter()
|
|
366
|
+
|
|
367
|
+
@app.after_request
|
|
368
|
+
def _analytics_timed(response):
|
|
369
|
+
started = getattr(g, "_analytics_started", None)
|
|
370
|
+
if started is not None:
|
|
371
|
+
rule = getattr(request, "url_rule", None)
|
|
372
|
+
self.time_request(request.method, getattr(rule, "rule", ""), response.status_code, started, request.path)
|
|
373
|
+
return response
|
|
374
|
+
|
|
375
|
+
def on_error(sender, exception, **_):
|
|
376
|
+
rule = getattr(request, "url_rule", None)
|
|
377
|
+
self.capture_exception(exception, status=500, kind="error", request={
|
|
378
|
+
"method": request.method, "path": request.path, "route": getattr(rule, "rule", ""),
|
|
379
|
+
"session_id": request.headers.get(SESSION_HEADER, ""),
|
|
380
|
+
})
|
|
381
|
+
|
|
382
|
+
got_request_exception.connect(on_error, app, weak=False)
|
|
383
|
+
return app
|
|
384
|
+
|
|
385
|
+
def asgi(self, app):
|
|
386
|
+
"""ASGI middleware around any ASGI app (FastAPI, Starlette, Django ASGI)."""
|
|
387
|
+
return PlatformMiddleware(app, platform=self)
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def _wsgi_body(environ):
|
|
391
|
+
try:
|
|
392
|
+
length = int(environ.get("CONTENT_LENGTH") or 0)
|
|
393
|
+
except ValueError:
|
|
394
|
+
return b""
|
|
395
|
+
return environ["wsgi.input"].read(length) if length > 0 else b""
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
class _Headers:
|
|
399
|
+
"""The security headers module: the headers come with the module switches (core)."""
|
|
400
|
+
|
|
401
|
+
def flush(self):
|
|
402
|
+
pass
|
|
403
|
+
|
|
404
|
+
def close(self):
|
|
405
|
+
pass
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
class PlatformMiddleware:
|
|
409
|
+
"""ASGI middleware: app.add_middleware(PlatformMiddleware, platform=platform)."""
|
|
410
|
+
|
|
411
|
+
def __init__(self, app, platform):
|
|
412
|
+
self.app = app
|
|
413
|
+
self.platform = platform
|
|
414
|
+
|
|
415
|
+
async def __call__(self, scope, receive, send):
|
|
416
|
+
if scope.get("type") != "http":
|
|
417
|
+
return await self.app(scope, receive, send)
|
|
418
|
+
headers = {k.decode("latin-1").lower(): v.decode("latin-1") for k, v in scope.get("headers") or []}
|
|
419
|
+
client = scope.get("client") or ("",)
|
|
420
|
+
# The tracker's own addresses, answered before anything else: a
|
|
421
|
+
# visitor's beacon is our traffic, not a page of the site. Sending
|
|
422
|
+
# runs in a thread, so the event loop keeps turning while it waits.
|
|
423
|
+
proxy = self.platform.proxy
|
|
424
|
+
if proxy is not None and proxy.match(scope.get("path", "")) is not None:
|
|
425
|
+
body = b""
|
|
426
|
+
while True:
|
|
427
|
+
message = await receive()
|
|
428
|
+
body += message.get("body", b"") or b""
|
|
429
|
+
if not message.get("more_body"):
|
|
430
|
+
break
|
|
431
|
+
answered = await asyncio.get_running_loop().run_in_executor(
|
|
432
|
+
None,
|
|
433
|
+
lambda: self.platform.proxy_response(
|
|
434
|
+
scope.get("path", ""), scope.get("method", ""),
|
|
435
|
+
scope.get("query_string", b"").decode("latin-1"), headers, body, client[0] or ""),
|
|
436
|
+
)
|
|
437
|
+
status, extra, out = answered or (404, [], b"")
|
|
438
|
+
await send({"type": "http.response.start", "status": status,
|
|
439
|
+
"headers": [(k.lower().encode(), str(v).encode()) for k, v in extra]})
|
|
440
|
+
await send({"type": "http.response.body", "body": out})
|
|
441
|
+
return
|
|
442
|
+
blocked = self.platform.blocked(client[0] or "", headers, scope.get("method", ""), scope.get("path", ""))
|
|
443
|
+
if blocked:
|
|
444
|
+
status, body, extra = blocked
|
|
445
|
+
await send({"type": "http.response.start", "status": status,
|
|
446
|
+
"headers": [(k.lower().encode(), v.encode()) for k, v in extra]})
|
|
447
|
+
await send({"type": "http.response.body", "body": body})
|
|
448
|
+
return
|
|
449
|
+
query = scope.get("query_string", b"").decode("latin-1")
|
|
450
|
+
closed = self.platform.maintenance_response(client[0] or "", headers, scope.get("path", ""), query)
|
|
451
|
+
if closed and closed[0] != "pass":
|
|
452
|
+
await send({"type": "http.response.start", "status": 503, "headers": [(k.lower().encode(), v.encode()) for k, v in closed[1]]})
|
|
453
|
+
await send({"type": "http.response.body", "body": closed[2]})
|
|
454
|
+
return
|
|
455
|
+
remember = [(k.lower().encode(), v.encode()) for k, v in closed[1]] if closed else []
|
|
456
|
+
started = time.perf_counter()
|
|
457
|
+
seen = {"status": 500}
|
|
458
|
+
|
|
459
|
+
async def timed_send(message):
|
|
460
|
+
if message.get("type") == "http.response.start":
|
|
461
|
+
seen["status"] = int(message.get("status") or 500)
|
|
462
|
+
present = [k.decode("latin-1") for k, _ in message.get("headers") or []]
|
|
463
|
+
extra = self.platform.security_headers(present)
|
|
464
|
+
if extra or remember:
|
|
465
|
+
message = dict(message, headers=list(message.get("headers") or []) + [(n.lower().encode(), v.encode("latin-1", "replace")) for n, v in extra] + remember)
|
|
466
|
+
await send(message)
|
|
467
|
+
|
|
468
|
+
try:
|
|
469
|
+
await self.app(scope, receive, timed_send)
|
|
470
|
+
except Exception as error:
|
|
471
|
+
self.platform.capture_exception(error, status=500, kind="error", request={
|
|
472
|
+
"method": scope.get("method", ""), "path": scope.get("path", ""),
|
|
473
|
+
"route": getattr(scope.get("route"), "path", ""), "session_id": headers.get(SESSION_HEADER, ""),
|
|
474
|
+
})
|
|
475
|
+
raise
|
|
476
|
+
finally:
|
|
477
|
+
# The router sets scope["route"] (FastAPI, Starlette) on the way in.
|
|
478
|
+
self.platform.time_request(scope.get("method", ""), getattr(scope.get("route"), "path", "") or "",
|
|
479
|
+
seen["status"], started, scope.get("path", ""))
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def create_platform(**options):
|
|
483
|
+
return Platform(**options)
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Analytics module: what only the server sees for sure (payments, sign-ups,
|
|
2
|
+
plan changes), next to the browser tracker's visits. Not pageviews: the
|
|
3
|
+
tracker counts those, and counting them here too would count them twice.
|
|
4
|
+
|
|
5
|
+
- track(): events, batched to POST /events/server-events in the background.
|
|
6
|
+
Each gets an event_uid, so a retried batch can be told apart from new events.
|
|
7
|
+
- identify(): links the browser's visit to your user ID (/events/identify).
|
|
8
|
+
- lead(): adds or updates a lead (/events/lead).
|
|
9
|
+
identify() and lead() wait for the answer (up to 5 seconds).
|
|
10
|
+
Pass session_id to link an event to the visit: the page sends
|
|
11
|
+
window.analyticsSession.getId() in the X-Analytics-Session header, and the
|
|
12
|
+
framework integrations give you platform.session_id(request).
|
|
13
|
+
"""
|
|
14
|
+
import threading
|
|
15
|
+
import uuid
|
|
16
|
+
from datetime import datetime, timezone
|
|
17
|
+
|
|
18
|
+
from .core import Periodic
|
|
19
|
+
|
|
20
|
+
BATCH = 100
|
|
21
|
+
QUEUE_MAX = 1000
|
|
22
|
+
FLUSH_EVERY = 2
|
|
23
|
+
RETRY = 15
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Analytics:
|
|
27
|
+
def __init__(self, core, **_):
|
|
28
|
+
self.core = core
|
|
29
|
+
self.queue = []
|
|
30
|
+
self.lock = threading.Lock()
|
|
31
|
+
self.job = Periodic(self._flush_job, FLUSH_EVERY) if core.valid else None
|
|
32
|
+
|
|
33
|
+
def track(self, name, fields=None, session_id=""):
|
|
34
|
+
"""name: the event's name; fields: value, numeric_value, currency, url,
|
|
35
|
+
event_label, variant, id, timestamp, event_uid (the server events docs).
|
|
36
|
+
Returns the event_uid, or "" when not queued."""
|
|
37
|
+
if not name or not isinstance(name, str) or not self.core.is_on("analytics"):
|
|
38
|
+
return ""
|
|
39
|
+
fields = dict(fields or {})
|
|
40
|
+
event = {
|
|
41
|
+
"timestamp": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
|
|
42
|
+
"session_id": session_id or "",
|
|
43
|
+
}
|
|
44
|
+
event.update(fields)
|
|
45
|
+
event["event_name"] = name[:100]
|
|
46
|
+
event["event_uid"] = fields.get("event_uid") or str(uuid.uuid4())
|
|
47
|
+
if isinstance(event.get("numeric_value"), (int, float)):
|
|
48
|
+
event["numeric_value"] = str(event["numeric_value"])
|
|
49
|
+
with self.lock:
|
|
50
|
+
if len(self.queue) >= QUEUE_MAX:
|
|
51
|
+
self.core.log("analytics", "queue full, event dropped")
|
|
52
|
+
return ""
|
|
53
|
+
self.queue.append(event)
|
|
54
|
+
full = len(self.queue) >= BATCH
|
|
55
|
+
if full and self.job:
|
|
56
|
+
self.job.run_now()
|
|
57
|
+
return event["event_uid"]
|
|
58
|
+
|
|
59
|
+
def flush(self):
|
|
60
|
+
"""Sends what's waiting; returns False when some of it must be tried again."""
|
|
61
|
+
with self.lock:
|
|
62
|
+
waiting, self.queue = self.queue, []
|
|
63
|
+
ok = True
|
|
64
|
+
for i in range(0, len(waiting), BATCH):
|
|
65
|
+
batch = waiting[i:i + BATCH]
|
|
66
|
+
res = self.core.send("analytics", "/events/server-events", body={"events": batch})
|
|
67
|
+
if res is None or res.status == 429 or res.status >= 500:
|
|
68
|
+
ok = False
|
|
69
|
+
with self.lock:
|
|
70
|
+
self.queue = (batch + self.queue)[:QUEUE_MAX]
|
|
71
|
+
elif not res.ok:
|
|
72
|
+
self.core.log("analytics", "events refused:", res.body[:300])
|
|
73
|
+
return ok
|
|
74
|
+
|
|
75
|
+
def _flush_job(self):
|
|
76
|
+
return FLUSH_EVERY if self.flush() else RETRY
|
|
77
|
+
|
|
78
|
+
def identify(self, user_id, session_id=""):
|
|
79
|
+
"""Links the visit to your user ID. True when stored."""
|
|
80
|
+
if not user_id or not session_id or not self.core.is_on("analytics"):
|
|
81
|
+
return False
|
|
82
|
+
res = self.core.send("analytics", "/events/identify", body={"user_id": str(user_id), "session_id": session_id})
|
|
83
|
+
return bool(res and res.ok)
|
|
84
|
+
|
|
85
|
+
def lead(self, fields, session_id=""):
|
|
86
|
+
"""fields: email or phone, and name, status, tags, note, deal fields...
|
|
87
|
+
Returns {"status", "lead_id"}, or None."""
|
|
88
|
+
if not self.core.is_on("analytics"):
|
|
89
|
+
return None
|
|
90
|
+
res = self.core.send("analytics", "/events/lead", body=dict({"session_id": session_id or ""}, **(fields or {})))
|
|
91
|
+
if res is None:
|
|
92
|
+
return None
|
|
93
|
+
if not res.ok:
|
|
94
|
+
self.core.log("analytics", "lead refused:", res.body[:300])
|
|
95
|
+
return None
|
|
96
|
+
return res.json()
|
|
97
|
+
|
|
98
|
+
def close(self):
|
|
99
|
+
if self.job:
|
|
100
|
+
self.job.stop()
|
|
101
|
+
self.flush()
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Audit log module: who did what on your site, sent to POST /events/audit in
|
|
2
|
+
small batches in the background (Security -> Audit log).
|
|
3
|
+
audit("user.role_changed", actor=..., target=..., details=..., request=...);
|
|
4
|
+
the request adds the actor's address and visit. What goes in (names, emails)
|
|
5
|
+
is up to you. Django's logins and logouts are recorded on their own.
|
|
6
|
+
"""
|
|
7
|
+
import threading
|
|
8
|
+
from datetime import datetime, timezone
|
|
9
|
+
|
|
10
|
+
from .core import Periodic
|
|
11
|
+
|
|
12
|
+
BATCH = 100
|
|
13
|
+
QUEUE_MAX = 1000
|
|
14
|
+
FLUSH_EVERY = 5
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Audit:
|
|
18
|
+
def __init__(self, core, **_):
|
|
19
|
+
self.core = core
|
|
20
|
+
self.queue = []
|
|
21
|
+
self.lock = threading.Lock()
|
|
22
|
+
self.job = Periodic(self._flush_job, FLUSH_EVERY) if core.valid else None
|
|
23
|
+
|
|
24
|
+
def record(self, action, actor="", target="", details=None, actor_ip="", session_id=""):
|
|
25
|
+
if not self.core.is_on("audit") or not isinstance(action, str) or not action:
|
|
26
|
+
return
|
|
27
|
+
entry = {
|
|
28
|
+
"at": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
|
|
29
|
+
"action": action[:64], "actor": str(actor or "")[:200], "target": str(target or "")[:300],
|
|
30
|
+
"details": details, "actor_ip": actor_ip or "", "session_id": session_id or "",
|
|
31
|
+
}
|
|
32
|
+
with self.lock:
|
|
33
|
+
if len(self.queue) >= QUEUE_MAX:
|
|
34
|
+
return
|
|
35
|
+
self.queue.append(entry)
|
|
36
|
+
full = len(self.queue) >= BATCH
|
|
37
|
+
if full and self.job:
|
|
38
|
+
self.job.run_now()
|
|
39
|
+
|
|
40
|
+
def flush(self):
|
|
41
|
+
with self.lock:
|
|
42
|
+
waiting, self.queue = self.queue, []
|
|
43
|
+
for i in range(0, len(waiting), BATCH):
|
|
44
|
+
self.core.send("audit", "/events/audit", body={"entries": waiting[i:i + BATCH]})
|
|
45
|
+
|
|
46
|
+
def _flush_job(self):
|
|
47
|
+
self.flush()
|
|
48
|
+
return FLUSH_EVERY
|
|
49
|
+
|
|
50
|
+
def close(self):
|
|
51
|
+
if self.job:
|
|
52
|
+
self.job.stop()
|
|
53
|
+
self.flush()
|