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,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()