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,199 @@
|
|
|
1
|
+
"""What every module shares: the site's credentials, sending to the
|
|
2
|
+
collector, logging, background jobs, and the dashboard's module switches
|
|
3
|
+
(Site settings -> Server, GET /events/platform/config about once a minute).
|
|
4
|
+
|
|
5
|
+
A module the dashboard turns off stays loaded but does nothing; until the
|
|
6
|
+
switches arrive, or when they can't be fetched, every loaded module stays on.
|
|
7
|
+
Sending never raises into the app. Background jobs are daemon threads that
|
|
8
|
+
start again in a forked worker (gunicorn, uWSGI), where threads don't survive.
|
|
9
|
+
"""
|
|
10
|
+
import atexit
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import re
|
|
14
|
+
import threading
|
|
15
|
+
import urllib.error
|
|
16
|
+
import urllib.request
|
|
17
|
+
|
|
18
|
+
VERSION = "0.1.0"
|
|
19
|
+
TIMEOUT = 5
|
|
20
|
+
MIN_SYNC = 30
|
|
21
|
+
RETRY = 60
|
|
22
|
+
SESSION_HEADER = "x-analytics-session"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Response:
|
|
26
|
+
def __init__(self, status, headers, body):
|
|
27
|
+
self.status = status
|
|
28
|
+
self.headers = headers
|
|
29
|
+
self.body = body
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def ok(self):
|
|
33
|
+
return 200 <= self.status < 300
|
|
34
|
+
|
|
35
|
+
def json(self):
|
|
36
|
+
try:
|
|
37
|
+
return json.loads(self.body or b"null")
|
|
38
|
+
except ValueError:
|
|
39
|
+
return None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
_running = []
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _stop_all():
|
|
46
|
+
"""At exit: background threads finish before Python shuts down (a daemon
|
|
47
|
+
thread still running then can crash the interpreter)."""
|
|
48
|
+
for job in list(_running):
|
|
49
|
+
job.stop()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
atexit.register(_stop_all)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class Periodic:
|
|
56
|
+
"""Runs job() in a daemon thread; job returns the seconds until its next run."""
|
|
57
|
+
|
|
58
|
+
def __init__(self, job, first=0.0):
|
|
59
|
+
self.job = job
|
|
60
|
+
self.first = first
|
|
61
|
+
self.stopped = threading.Event()
|
|
62
|
+
self.wake = threading.Event()
|
|
63
|
+
self.thread = None
|
|
64
|
+
self._start()
|
|
65
|
+
_running.append(self)
|
|
66
|
+
if hasattr(os, "register_at_fork"):
|
|
67
|
+
os.register_at_fork(after_in_child=self._start)
|
|
68
|
+
|
|
69
|
+
def _start(self):
|
|
70
|
+
if self.stopped.is_set():
|
|
71
|
+
return
|
|
72
|
+
self.wake = threading.Event()
|
|
73
|
+
self.thread = threading.Thread(target=self._run, daemon=True, name="analytics-platform")
|
|
74
|
+
self.thread.start()
|
|
75
|
+
|
|
76
|
+
def _run(self):
|
|
77
|
+
wait = self.first
|
|
78
|
+
while not self.stopped.is_set():
|
|
79
|
+
self.wake.wait(wait)
|
|
80
|
+
self.wake.clear()
|
|
81
|
+
if self.stopped.is_set():
|
|
82
|
+
return
|
|
83
|
+
try:
|
|
84
|
+
wait = self.job()
|
|
85
|
+
except Exception: # never let a job die
|
|
86
|
+
wait = RETRY
|
|
87
|
+
|
|
88
|
+
def run_now(self):
|
|
89
|
+
self.wake.set()
|
|
90
|
+
|
|
91
|
+
def stop(self, wait=2.0):
|
|
92
|
+
"""Stops the job and waits (up to wait seconds) for its thread to end."""
|
|
93
|
+
self.stopped.set()
|
|
94
|
+
self.wake.set()
|
|
95
|
+
if self in _running:
|
|
96
|
+
_running.remove(self)
|
|
97
|
+
thread = self.thread
|
|
98
|
+
if wait and thread is not None and thread.is_alive() and thread is not threading.current_thread():
|
|
99
|
+
thread.join(wait)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class Core:
|
|
103
|
+
def __init__(self, site_id, secret, endpoint, debug=False):
|
|
104
|
+
self.site_id = str(site_id or "")
|
|
105
|
+
self.secret = str(secret or "")
|
|
106
|
+
self.endpoint = str(endpoint or "").rstrip("/")
|
|
107
|
+
self.valid = bool(re.fullmatch(r"[a-fA-F0-9]{24}", self.site_id) and self.secret and self.endpoint)
|
|
108
|
+
self.debug = bool(debug)
|
|
109
|
+
self.user_agent = "analytics-platform-python/" + VERSION
|
|
110
|
+
self._switches = {}
|
|
111
|
+
self._headers = []
|
|
112
|
+
self._flags = {}
|
|
113
|
+
self._maintenance = {"on": False}
|
|
114
|
+
self._etag = ""
|
|
115
|
+
self._loaded = []
|
|
116
|
+
self._job = None
|
|
117
|
+
|
|
118
|
+
def log(self, module, *args):
|
|
119
|
+
if self.debug:
|
|
120
|
+
print("[analytics-platform:%s]" % module, *args)
|
|
121
|
+
|
|
122
|
+
def send(self, module, path, method="POST", body=None, auth="body", headers=None):
|
|
123
|
+
"""JSON to the collector. auth "body" puts site_id and sitesecret in
|
|
124
|
+
the body (the event endpoints), "bearer" sends the secret as a header
|
|
125
|
+
(firewall, switches). Returns a Response, or None when it couldn't be sent."""
|
|
126
|
+
if not self.valid:
|
|
127
|
+
return None
|
|
128
|
+
all_headers = {"User-Agent": self.user_agent}
|
|
129
|
+
data = None
|
|
130
|
+
if body is not None:
|
|
131
|
+
if auth == "body":
|
|
132
|
+
body = dict(body, site_id=self.site_id, sitesecret=self.secret)
|
|
133
|
+
data = json.dumps(body).encode("utf-8")
|
|
134
|
+
all_headers["Content-Type"] = "application/json"
|
|
135
|
+
if auth == "bearer":
|
|
136
|
+
all_headers["Authorization"] = "Bearer " + self.secret
|
|
137
|
+
all_headers.update(headers or {})
|
|
138
|
+
request = urllib.request.Request(self.endpoint + path, data=data, headers=all_headers, method=method)
|
|
139
|
+
try:
|
|
140
|
+
with urllib.request.urlopen(request, timeout=TIMEOUT) as res:
|
|
141
|
+
return Response(res.status, {k.lower(): v for k, v in res.headers.items()}, res.read())
|
|
142
|
+
except urllib.error.HTTPError as error:
|
|
143
|
+
if error.code != 304:
|
|
144
|
+
self.log(module, "collector answered %s to %s" % (error.code, path))
|
|
145
|
+
return Response(error.code, {k.lower(): v for k, v in (error.headers or {}).items()}, error.read() if error.fp else b"")
|
|
146
|
+
except Exception as error:
|
|
147
|
+
self.log(module, "could not reach the collector (%s): %s" % (path, error))
|
|
148
|
+
return None
|
|
149
|
+
|
|
150
|
+
# ---- Module switches ----
|
|
151
|
+
|
|
152
|
+
def is_on(self, name):
|
|
153
|
+
"""False only when the dashboard turned the module off."""
|
|
154
|
+
return self._switches.get(name) is not False
|
|
155
|
+
|
|
156
|
+
def _sync_switches(self):
|
|
157
|
+
headers = {"X-Platform-Modules": ",".join(self._loaded)}
|
|
158
|
+
if self._etag:
|
|
159
|
+
headers["If-None-Match"] = self._etag
|
|
160
|
+
res = self.send("core", "/events/platform/config?site_id=" + self.site_id, method="GET", auth="bearer", headers=headers)
|
|
161
|
+
if res is not None and res.status == 200:
|
|
162
|
+
data = res.json() or {}
|
|
163
|
+
if data.get("v") == 1 and isinstance(data.get("modules"), dict):
|
|
164
|
+
self._switches = data["modules"]
|
|
165
|
+
self._flags = data.get("flags") if isinstance(data.get("flags"), dict) else {}
|
|
166
|
+
self._maintenance = data.get("maintenance") if isinstance(data.get("maintenance"), dict) else {"on": False}
|
|
167
|
+
# Only well-formed headers (no line breaks can get through).
|
|
168
|
+
self._headers = [(p[0], p[1]) for p in data.get("headers") or [] if isinstance(p, list) and len(p) == 2
|
|
169
|
+
and isinstance(p[0], str) and re.fullmatch(r"[A-Za-z][A-Za-z0-9-]{0,63}", p[0])
|
|
170
|
+
and isinstance(p[1], str) and "\r" not in p[1] and "\n" not in p[1] and len(p[1]) <= 4000]
|
|
171
|
+
self._etag = res.headers.get("etag", "")
|
|
172
|
+
return max(MIN_SYNC, int(data.get("sync") or RETRY))
|
|
173
|
+
return RETRY
|
|
174
|
+
|
|
175
|
+
def flags(self):
|
|
176
|
+
return self._flags
|
|
177
|
+
|
|
178
|
+
def maintenance(self):
|
|
179
|
+
return self._maintenance
|
|
180
|
+
|
|
181
|
+
def security_headers(self):
|
|
182
|
+
"""The security headers picked on the dashboard, when the module is on."""
|
|
183
|
+
return list(self._headers) if self.is_on("headers") else []
|
|
184
|
+
|
|
185
|
+
def start(self, names):
|
|
186
|
+
"""Starts fetching the switches; names are the modules the code loaded."""
|
|
187
|
+
self._loaded = list(names)
|
|
188
|
+
if self.valid:
|
|
189
|
+
self._job = Periodic(self._sync_switches)
|
|
190
|
+
|
|
191
|
+
def stop(self):
|
|
192
|
+
if self._job:
|
|
193
|
+
self._job.stop()
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def header(headers, name):
|
|
197
|
+
"""A header from a dict with lowercase names (or None)."""
|
|
198
|
+
value = headers.get(name.lower())
|
|
199
|
+
return value.strip() if isinstance(value, str) else ""
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""Crawlers module: counts the search engines and AI bots that fetch the site
|
|
2
|
+
and sends one row a minute per bot, page and status to POST /events/crawlers
|
|
3
|
+
(the protocol is sdk/protocols/crawlers.md). This traffic is invisible to the
|
|
4
|
+
browser tracker, because crawlers don't run JavaScript: the server is the only
|
|
5
|
+
place it can be counted.
|
|
6
|
+
|
|
7
|
+
Only requests whose user agent names a known crawler are counted, so visitors
|
|
8
|
+
are never counted twice. Nothing about a visitor is sent; bot addresses go
|
|
9
|
+
with the batch only so the collector can check a crawler is itself, and are
|
|
10
|
+
not stored.
|
|
11
|
+
"""
|
|
12
|
+
import platform as host
|
|
13
|
+
import re
|
|
14
|
+
import socket
|
|
15
|
+
import threading
|
|
16
|
+
import time
|
|
17
|
+
from datetime import datetime, timezone
|
|
18
|
+
|
|
19
|
+
from .core import Periodic
|
|
20
|
+
|
|
21
|
+
FLUSH_EVERY = 60
|
|
22
|
+
MAX_KEYS = 300
|
|
23
|
+
MAX_ROWS = 500
|
|
24
|
+
MAX_IPS_PER_BOT = 5
|
|
25
|
+
MAX_PATH = 400
|
|
26
|
+
|
|
27
|
+
# The catalogue of the protocol, most specific first: "ChatGPT-User" must be
|
|
28
|
+
# tried before "GPTBot", "Claude-User" before "ClaudeBot". Keep in step with
|
|
29
|
+
# the collector (expresscollect/Analytics/crawlers.js CATALOGUE).
|
|
30
|
+
BOTS = [
|
|
31
|
+
# Fetched because a person asked an AI about the page.
|
|
32
|
+
("ChatGPT-User", r"ChatGPT-User"),
|
|
33
|
+
("OAI-SearchBot", r"OAI-SearchBot"),
|
|
34
|
+
("Perplexity-User", r"Perplexity-User"),
|
|
35
|
+
("Claude-User", r"Claude-User"),
|
|
36
|
+
("Claude-SearchBot", r"Claude-SearchBot"),
|
|
37
|
+
("MistralAI-User", r"MistralAI-User"),
|
|
38
|
+
("MistralAI-SearchBot", r"MistralAI-SearchBot"),
|
|
39
|
+
("Meta-ExternalFetcher", r"meta-externalfetcher"),
|
|
40
|
+
# Bulk collection, for training or a dataset.
|
|
41
|
+
("GPTBot", r"GPTBot"),
|
|
42
|
+
("ClaudeBot", r"ClaudeBot|Claude-Web"),
|
|
43
|
+
("anthropic-ai", r"anthropic-ai"),
|
|
44
|
+
("CCBot", r"CCBot"),
|
|
45
|
+
("Bytespider", r"Bytespider"),
|
|
46
|
+
("Meta-ExternalAgent", r"meta-externalagent|facebookbot"),
|
|
47
|
+
("Amazonbot", r"Amazonbot"),
|
|
48
|
+
("Diffbot", r"Diffbot"),
|
|
49
|
+
("omgili", r"omgili"),
|
|
50
|
+
("AI2Bot", r"AI2Bot"),
|
|
51
|
+
("Timpibot", r"Timpibot"),
|
|
52
|
+
("Webzio-Extended", r"Webzio-Extended"),
|
|
53
|
+
("PerplexityBot", r"PerplexityBot"),
|
|
54
|
+
("DuckAssistBot", r"DuckAssistBot"),
|
|
55
|
+
("YouBot", r"YouBot"),
|
|
56
|
+
# Search engines. Google's tools before Googlebot itself.
|
|
57
|
+
("Google-InspectionTool", r"Google-InspectionTool"),
|
|
58
|
+
("Storebot-Google", r"Storebot-Google"),
|
|
59
|
+
("AdsBot-Google", r"AdsBot-Google"),
|
|
60
|
+
("Googlebot", r"Googlebot|Google-Site-Verification"),
|
|
61
|
+
("bingbot", r"bingbot|BingPreview|adidxbot"),
|
|
62
|
+
("YandexBot", r"Yandex(Bot|Mobile|Images|Webmaster)?"),
|
|
63
|
+
("Baiduspider", r"Baiduspider"),
|
|
64
|
+
("DuckDuckBot", r"DuckDuckBot|DuckDuckGo-Favicons"),
|
|
65
|
+
("Applebot", r"Applebot"),
|
|
66
|
+
("PetalBot", r"PetalBot|AspiegelBot"),
|
|
67
|
+
("SeznamBot", r"SeznamBot"),
|
|
68
|
+
("Yeti", r"Yeti/"),
|
|
69
|
+
("MojeekBot", r"MojeekBot"),
|
|
70
|
+
("Qwantbot", r"Qwantbot|Qwantify"),
|
|
71
|
+
# SEO and backlink tools.
|
|
72
|
+
("AhrefsBot", r"AhrefsBot|AhrefsSiteAudit"),
|
|
73
|
+
("SemrushBot", r"SemrushBot|SiteAuditBot"),
|
|
74
|
+
("DotBot", r"DotBot"),
|
|
75
|
+
("MJ12bot", r"MJ12bot"),
|
|
76
|
+
("BLEXBot", r"BLEXBot"),
|
|
77
|
+
("DataForSeoBot", r"DataForSeoBot"),
|
|
78
|
+
("Barkrowler", r"Barkrowler"),
|
|
79
|
+
("ScreamingFrog", r"Screaming\s?Frog"),
|
|
80
|
+
("ZoominfoBot", r"ZoominfoBot"),
|
|
81
|
+
# Link previews.
|
|
82
|
+
("facebookexternalhit", r"facebookexternalhit"),
|
|
83
|
+
("Twitterbot", r"Twitterbot"),
|
|
84
|
+
("LinkedInBot", r"LinkedInBot"),
|
|
85
|
+
("Slackbot", r"Slackbot|Slack-ImgProxy"),
|
|
86
|
+
("Discordbot", r"Discordbot"),
|
|
87
|
+
("TelegramBot", r"TelegramBot"),
|
|
88
|
+
("WhatsApp", r"WhatsApp"),
|
|
89
|
+
("Pinterestbot", r"Pinterest(bot)?/"),
|
|
90
|
+
("redditbot", r"redditbot"),
|
|
91
|
+
# Uptime and speed checks.
|
|
92
|
+
("UptimeRobot", r"UptimeRobot"),
|
|
93
|
+
("Pingdom", r"Pingdom"),
|
|
94
|
+
("BetterUptime", r"Better\s?Uptime|betteruptime"),
|
|
95
|
+
("Lighthouse", r"Chrome-Lighthouse|Google Page Speed"),
|
|
96
|
+
("GTmetrix", r"GTmetrix"),
|
|
97
|
+
# Archives.
|
|
98
|
+
("ia_archiver", r"ia_archiver"),
|
|
99
|
+
("archive.org_bot", r"archive\.org_bot|Wayback"),
|
|
100
|
+
]
|
|
101
|
+
_BOTS = [(name, re.compile(pattern, re.I)) for name, pattern in BOTS]
|
|
102
|
+
|
|
103
|
+
# Anything else that names itself a bot: counted as "Other bot", so a new
|
|
104
|
+
# crawler shows up as traffic instead of disappearing.
|
|
105
|
+
_GENERIC = re.compile(
|
|
106
|
+
r"bot\b|crawler|crawling|spider|scrapy|curl/|wget/|python-requests|headlesschrome|http-client|okhttp|java/|go-http-client",
|
|
107
|
+
re.I,
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def detect(user_agent):
|
|
112
|
+
"""The crawler's catalogue name, or "" when the user agent isn't one."""
|
|
113
|
+
ua = str(user_agent or "")
|
|
114
|
+
if not ua:
|
|
115
|
+
return ""
|
|
116
|
+
for name, pattern in _BOTS:
|
|
117
|
+
if pattern.search(ua):
|
|
118
|
+
return name
|
|
119
|
+
return "Other bot" if _GENERIC.search(ua) else ""
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class Crawlers:
|
|
123
|
+
"""Options: server_name (default the host name)."""
|
|
124
|
+
|
|
125
|
+
def __init__(self, core, server_name=None, **_):
|
|
126
|
+
self.core = core
|
|
127
|
+
self.server_name = str(server_name or socket.gethostname())[:100]
|
|
128
|
+
self.runtime = "Python " + host.python_version()
|
|
129
|
+
self.rows = {}
|
|
130
|
+
self.agents = {}
|
|
131
|
+
self.lock = threading.Lock()
|
|
132
|
+
self.job = Periodic(self._flush_job, FLUSH_EVERY) if core.valid else None
|
|
133
|
+
|
|
134
|
+
def record(self, bot, path, status, ms=0, size=0, ip=""):
|
|
135
|
+
"""One finished request from a crawler. bot: detect(user_agent)."""
|
|
136
|
+
if not bot or not self.core.is_on("crawlers"):
|
|
137
|
+
return
|
|
138
|
+
clean = (re.split(r"[?#]", str(path or "/"))[0] or "/")[:MAX_PATH]
|
|
139
|
+
minute = int(time.time() // 60) * 60
|
|
140
|
+
with self.lock:
|
|
141
|
+
key = (minute, bot, clean, int(status or 0))
|
|
142
|
+
# Past MAX_KEYS a crawler asking for thousands of URLs becomes one row.
|
|
143
|
+
if key not in self.rows and len(self.rows) >= MAX_KEYS:
|
|
144
|
+
key = (minute, bot, "(other)", int(status or 0))
|
|
145
|
+
row = self.rows.get(key)
|
|
146
|
+
if row is None:
|
|
147
|
+
row = self.rows[key] = {"count": 0, "bytes": 0, "sum_ms": 0}
|
|
148
|
+
row["count"] += 1
|
|
149
|
+
row["bytes"] += max(0, int(size or 0))
|
|
150
|
+
row["sum_ms"] += max(0, int(ms or 0))
|
|
151
|
+
if ip:
|
|
152
|
+
seen = self.agents.setdefault(bot, [])
|
|
153
|
+
if len(seen) < MAX_IPS_PER_BOT and ip not in seen:
|
|
154
|
+
seen.append(ip)
|
|
155
|
+
|
|
156
|
+
def middleware(self, user_agent, path, status, ms=0, size=0, ip=""):
|
|
157
|
+
"""Records a finished request when it came from a crawler."""
|
|
158
|
+
self.record(detect(user_agent), path, status, ms, size, ip)
|
|
159
|
+
|
|
160
|
+
def flush(self, everything=True):
|
|
161
|
+
current = int(time.time() // 60) * 60
|
|
162
|
+
with self.lock:
|
|
163
|
+
ready = [(k, v) for k, v in self.rows.items() if everything or k[0] < current]
|
|
164
|
+
for k, _ in ready:
|
|
165
|
+
del self.rows[k]
|
|
166
|
+
sent = {k[1] for k, _ in ready}
|
|
167
|
+
agents = [{"bot": bot, "ips": list(ips)} for bot, ips in self.agents.items() if bot in sent]
|
|
168
|
+
if ready:
|
|
169
|
+
# A bot with rows still waiting (this minute isn't over) keeps
|
|
170
|
+
# its addresses, so the next batch can be checked as well.
|
|
171
|
+
waiting = {key[1] for key in self.rows}
|
|
172
|
+
self.agents = {bot: ips for bot, ips in self.agents.items() if bot in waiting}
|
|
173
|
+
rows = [{
|
|
174
|
+
"minute": datetime.fromtimestamp(k[0], timezone.utc).isoformat().replace("+00:00", "Z"),
|
|
175
|
+
"bot": k[1], "path": k[2], "status": k[3],
|
|
176
|
+
"count": v["count"], "bytes": v["bytes"], "sum_ms": v["sum_ms"],
|
|
177
|
+
} for k, v in ready]
|
|
178
|
+
for i in range(0, len(rows), MAX_ROWS):
|
|
179
|
+
self.core.send("crawlers", "/events/crawlers", body={
|
|
180
|
+
"server_name": self.server_name, "runtime": self.runtime,
|
|
181
|
+
"rows": rows[i:i + MAX_ROWS], "agents": agents if i == 0 else [],
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
def _flush_job(self):
|
|
185
|
+
self.flush(everything=False)
|
|
186
|
+
return FLUSH_EVERY
|
|
187
|
+
|
|
188
|
+
def close(self):
|
|
189
|
+
if self.job:
|
|
190
|
+
self.job.stop()
|
|
191
|
+
self.flush()
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Django: add the middleware first in MIDDLEWARE and the options to settings.
|
|
2
|
+
|
|
3
|
+
MIDDLEWARE = ["analytics_platform.django.PlatformMiddleware", ...]
|
|
4
|
+
ANALYTICS_PLATFORM = {
|
|
5
|
+
"site_id": "...",
|
|
6
|
+
"secret": os.environ["ANALYTICS_SITE_SECRET"],
|
|
7
|
+
"endpoint": "https://...",
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
In your code, get_platform() is the same platform: get_platform().track(...).
|
|
11
|
+
The visitor's address is REMOTE_ADDR unless you set trust_proxy.
|
|
12
|
+
"""
|
|
13
|
+
import time
|
|
14
|
+
|
|
15
|
+
from django.conf import settings
|
|
16
|
+
from django.http import HttpResponse
|
|
17
|
+
|
|
18
|
+
from . import SESSION_HEADER, Platform
|
|
19
|
+
|
|
20
|
+
_platform = None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def get_platform():
|
|
24
|
+
global _platform
|
|
25
|
+
if _platform is None:
|
|
26
|
+
_platform = Platform(**getattr(settings, "ANALYTICS_PLATFORM", {}))
|
|
27
|
+
_connect_login_failed()
|
|
28
|
+
return _platform
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _connect_login_failed():
|
|
32
|
+
"""Failed Django logins count for bot protection, on their own."""
|
|
33
|
+
try:
|
|
34
|
+
from django.contrib.auth.signals import user_login_failed
|
|
35
|
+
except Exception:
|
|
36
|
+
return
|
|
37
|
+
|
|
38
|
+
def on_failed(sender, credentials=None, request=None, **_):
|
|
39
|
+
if request is not None:
|
|
40
|
+
get_platform().login_failed(request)
|
|
41
|
+
|
|
42
|
+
user_login_failed.connect(on_failed, weak=False, dispatch_uid="analytics_platform_login_failed")
|
|
43
|
+
|
|
44
|
+
# Logins and logouts in the audit log.
|
|
45
|
+
from django.contrib.auth.signals import user_logged_in, user_logged_out
|
|
46
|
+
|
|
47
|
+
def on_login(sender, request=None, user=None, **_):
|
|
48
|
+
get_platform().audit("user.login", actor=getattr(user, "get_username", lambda: "")() if user else "", request=request)
|
|
49
|
+
|
|
50
|
+
def on_logout(sender, request=None, user=None, **_):
|
|
51
|
+
get_platform().audit("user.logout", actor=getattr(user, "get_username", lambda: "")() if user else "", request=request)
|
|
52
|
+
|
|
53
|
+
user_logged_in.connect(on_login, weak=False, dispatch_uid="analytics_platform_login")
|
|
54
|
+
user_logged_out.connect(on_logout, weak=False, dispatch_uid="analytics_platform_logout")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class PlatformMiddleware:
|
|
58
|
+
def __init__(self, get_response):
|
|
59
|
+
self.get_response = get_response
|
|
60
|
+
self.platform = get_platform()
|
|
61
|
+
|
|
62
|
+
def __call__(self, request):
|
|
63
|
+
headers = {k.lower(): v for k, v in request.headers.items()}
|
|
64
|
+
started = time.perf_counter()
|
|
65
|
+
ip = request.META.get("REMOTE_ADDR", "")
|
|
66
|
+
# The tracker's own addresses, answered before anything else: a
|
|
67
|
+
# visitor's beacon is our traffic, not a page of the site.
|
|
68
|
+
answered = self.platform.proxy_response(
|
|
69
|
+
request.path, request.method, request.META.get("QUERY_STRING", ""),
|
|
70
|
+
headers, request.body if request.method == "POST" else None, ip)
|
|
71
|
+
if answered:
|
|
72
|
+
status, extra, body = answered
|
|
73
|
+
response = HttpResponse(body, status=status)
|
|
74
|
+
for name, value in extra:
|
|
75
|
+
response[name] = value
|
|
76
|
+
return response
|
|
77
|
+
blocked = self.platform.blocked(ip, headers, request.method, request.path)
|
|
78
|
+
if blocked:
|
|
79
|
+
status, body, extra = blocked
|
|
80
|
+
response = HttpResponse(body, status=status)
|
|
81
|
+
for name, value in extra:
|
|
82
|
+
response[name] = value
|
|
83
|
+
# A crawler the firewall turned away is still a crawler, and
|
|
84
|
+
# counted with the status it got.
|
|
85
|
+
self.platform.record_crawler(headers.get("user-agent", ""), request.path, status, started, 0, ip)
|
|
86
|
+
return response
|
|
87
|
+
closed = self.platform.maintenance_response(request.META.get("REMOTE_ADDR", ""), headers, request.path, request.META.get("QUERY_STRING", ""))
|
|
88
|
+
if closed and closed[0] != "pass":
|
|
89
|
+
response = HttpResponse(closed[2], status=503)
|
|
90
|
+
for name, value in closed[1]:
|
|
91
|
+
response[name] = value
|
|
92
|
+
return response
|
|
93
|
+
response = self.get_response(request)
|
|
94
|
+
for name, value in self.platform.security_headers(response.headers.keys()):
|
|
95
|
+
response[name] = value
|
|
96
|
+
if closed:
|
|
97
|
+
response["Set-Cookie"] = closed[1][0][1]
|
|
98
|
+
match = getattr(request, "resolver_match", None)
|
|
99
|
+
route = getattr(match, "route", "") or ""
|
|
100
|
+
self.platform.time_request(request.method, ("/" + route.lstrip("^").rstrip("$").lstrip("/")) if route else "",
|
|
101
|
+
response.status_code, started, request.path)
|
|
102
|
+
self.platform.record_crawler(headers.get("user-agent", ""), request.path, response.status_code, started,
|
|
103
|
+
response.headers.get("Content-Length") or 0, ip)
|
|
104
|
+
return response
|
|
105
|
+
|
|
106
|
+
def process_exception(self, request, exception):
|
|
107
|
+
match = getattr(request, "resolver_match", None)
|
|
108
|
+
self.platform.capture_exception(exception, status=500, kind="error", request={
|
|
109
|
+
"method": request.method, "path": request.path, "route": getattr(match, "route", "") or "",
|
|
110
|
+
"session_id": request.headers.get(SESSION_HEADER, ""),
|
|
111
|
+
})
|
|
112
|
+
return None
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Error monitoring module: the server's errors go to the collector (POST
|
|
2
|
+
/events/server-errors), where they join the browser's errors in the Errors
|
|
3
|
+
report. Uncaught exceptions (main thread and other threads) are captured on
|
|
4
|
+
their own and Python still prints and exits as it would. The framework
|
|
5
|
+
integrations capture request errors (5xx); capture_exception() captures
|
|
6
|
+
errors you catch. Errors are sent in small batches; sending never raises.
|
|
7
|
+
"""
|
|
8
|
+
import os
|
|
9
|
+
import platform as host
|
|
10
|
+
import re
|
|
11
|
+
import socket
|
|
12
|
+
import sys
|
|
13
|
+
import threading
|
|
14
|
+
import traceback
|
|
15
|
+
|
|
16
|
+
from .core import Periodic
|
|
17
|
+
|
|
18
|
+
BATCH = 10
|
|
19
|
+
QUEUE_MAX = 100
|
|
20
|
+
FLUSH_EVERY = 2
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Errors:
|
|
24
|
+
"""Options: server_name (default the host name), release (which build this
|
|
25
|
+
is; default the ANALYTICS_RELEASE environment variable), root (paths in tracebacks
|
|
26
|
+
are made relative to it; default the working directory), capture_uncaught
|
|
27
|
+
(default True), before_send(event, error) to change an event or drop it
|
|
28
|
+
(return None)."""
|
|
29
|
+
|
|
30
|
+
def __init__(self, core, server_name=None, root=None, release=None, capture_uncaught=True, before_send=None, **_):
|
|
31
|
+
self.core = core
|
|
32
|
+
self.server_name = str(server_name or socket.gethostname())[:100]
|
|
33
|
+
self.root = str(root or os.getcwd()).rstrip("/\\")
|
|
34
|
+
# Which build this is, for "introduced in" in the Errors report.
|
|
35
|
+
self.release = re.sub(r"[^\w.+\-]", "", str(release or os.environ.get("ANALYTICS_RELEASE", "")).strip())[:64]
|
|
36
|
+
self.before_send = before_send
|
|
37
|
+
self.queue = []
|
|
38
|
+
self.lock = threading.Lock()
|
|
39
|
+
self.job = Periodic(self._flush_job, FLUSH_EVERY) if core.valid else None
|
|
40
|
+
self._previous = None
|
|
41
|
+
if capture_uncaught:
|
|
42
|
+
self._previous = (sys.excepthook, threading.excepthook)
|
|
43
|
+
sys.excepthook = self._excepthook
|
|
44
|
+
threading.excepthook = self._thread_excepthook
|
|
45
|
+
|
|
46
|
+
def _excepthook(self, kind, value, tb):
|
|
47
|
+
self.capture_exception(value, kind="error")
|
|
48
|
+
self.flush()
|
|
49
|
+
self._previous[0](kind, value, tb)
|
|
50
|
+
|
|
51
|
+
def _thread_excepthook(self, args):
|
|
52
|
+
if args.exc_value is not None:
|
|
53
|
+
self.capture_exception(args.exc_value, kind="error")
|
|
54
|
+
self.flush()
|
|
55
|
+
self._previous[1](args)
|
|
56
|
+
|
|
57
|
+
def _relative(self, stack):
|
|
58
|
+
return stack.replace(self.root + os.sep, "").replace(self.root + "/", "") if self.root else stack
|
|
59
|
+
|
|
60
|
+
def capture_exception(self, error, request=None, status=0, kind="handled"):
|
|
61
|
+
"""request: {"method", "path", "route", "session_id"} (the integrations fill it in)."""
|
|
62
|
+
if not self.core.is_on("errors"):
|
|
63
|
+
return
|
|
64
|
+
try:
|
|
65
|
+
is_error = isinstance(error, BaseException)
|
|
66
|
+
stack = "".join(traceback.format_exception(type(error), error, error.__traceback__)) if is_error else ""
|
|
67
|
+
event = {
|
|
68
|
+
"message": str(error) or (type(error).__name__ if is_error else "Unknown error"),
|
|
69
|
+
"error_type": type(error).__name__ if is_error else "Error",
|
|
70
|
+
"stack": self._relative(stack),
|
|
71
|
+
"kind": kind,
|
|
72
|
+
"status_code": int(status or 0),
|
|
73
|
+
"runtime": "Python",
|
|
74
|
+
"runtime_version": host.python_version(),
|
|
75
|
+
"os": sys.platform,
|
|
76
|
+
"server_name": self.server_name,
|
|
77
|
+
"release": self.release,
|
|
78
|
+
}
|
|
79
|
+
event.update({k: str(v or "") for k, v in (request or {}).items() if k in ("method", "path", "route", "session_id")})
|
|
80
|
+
if event.get("path"):
|
|
81
|
+
event["path"] = event["path"].split("?")[0]
|
|
82
|
+
if self.before_send:
|
|
83
|
+
event = self.before_send(event, error)
|
|
84
|
+
if not event:
|
|
85
|
+
return
|
|
86
|
+
with self.lock:
|
|
87
|
+
if len(self.queue) >= QUEUE_MAX:
|
|
88
|
+
self.core.log("errors", "queue full, error dropped")
|
|
89
|
+
return
|
|
90
|
+
self.queue.append(event)
|
|
91
|
+
full = len(self.queue) >= BATCH
|
|
92
|
+
if full and self.job:
|
|
93
|
+
self.job.run_now()
|
|
94
|
+
except Exception as failure:
|
|
95
|
+
self.core.log("errors", "could not capture error:", failure)
|
|
96
|
+
|
|
97
|
+
def flush(self):
|
|
98
|
+
with self.lock:
|
|
99
|
+
waiting, self.queue = self.queue, []
|
|
100
|
+
for i in range(0, len(waiting), BATCH):
|
|
101
|
+
self.core.send("errors", "/events/server-errors", body={"errors": waiting[i:i + BATCH]})
|
|
102
|
+
|
|
103
|
+
def _flush_job(self):
|
|
104
|
+
self.flush()
|
|
105
|
+
return FLUSH_EVERY
|
|
106
|
+
|
|
107
|
+
def close(self):
|
|
108
|
+
if self.job:
|
|
109
|
+
self.job.stop()
|
|
110
|
+
if self._previous:
|
|
111
|
+
sys.excepthook, threading.excepthook = self._previous
|
|
112
|
+
self._previous = None
|
|
113
|
+
self.flush()
|