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