analytics-platform 0.2.2__tar.gz → 0.3.0__tar.gz

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.
Files changed (33) hide show
  1. {analytics_platform-0.2.2 → analytics_platform-0.3.0}/PKG-INFO +24 -2
  2. {analytics_platform-0.2.2 → analytics_platform-0.3.0}/README.md +23 -1
  3. {analytics_platform-0.2.2 → analytics_platform-0.3.0}/analytics_platform/__init__.py +75 -1
  4. {analytics_platform-0.2.2 → analytics_platform-0.3.0}/analytics_platform/core.py +1 -1
  5. {analytics_platform-0.2.2 → analytics_platform-0.3.0}/analytics_platform/django.py +22 -10
  6. analytics_platform-0.3.0/analytics_platform/errors.py +322 -0
  7. {analytics_platform-0.2.2 → analytics_platform-0.3.0}/analytics_platform.egg-info/PKG-INFO +24 -2
  8. {analytics_platform-0.2.2 → analytics_platform-0.3.0}/analytics_platform.egg-info/SOURCES.txt +1 -0
  9. {analytics_platform-0.2.2 → analytics_platform-0.3.0}/pyproject.toml +1 -1
  10. analytics_platform-0.3.0/tests/test_errors.py +253 -0
  11. analytics_platform-0.2.2/analytics_platform/errors.py +0 -113
  12. {analytics_platform-0.2.2 → analytics_platform-0.3.0}/LICENSE +0 -0
  13. {analytics_platform-0.2.2 → analytics_platform-0.3.0}/analytics_platform/analytics.py +0 -0
  14. {analytics_platform-0.2.2 → analytics_platform-0.3.0}/analytics_platform/audit.py +0 -0
  15. {analytics_platform-0.2.2 → analytics_platform-0.3.0}/analytics_platform/crawlers.py +0 -0
  16. {analytics_platform-0.2.2 → analytics_platform-0.3.0}/analytics_platform/firewall.py +0 -0
  17. {analytics_platform-0.2.2 → analytics_platform-0.3.0}/analytics_platform/flags.py +0 -0
  18. {analytics_platform-0.2.2 → analytics_platform-0.3.0}/analytics_platform/forms.py +0 -0
  19. {analytics_platform-0.2.2 → analytics_platform-0.3.0}/analytics_platform/inventory.py +0 -0
  20. {analytics_platform-0.2.2 → analytics_platform-0.3.0}/analytics_platform/maintenance.py +0 -0
  21. {analytics_platform-0.2.2 → analytics_platform-0.3.0}/analytics_platform/performance.py +0 -0
  22. {analytics_platform-0.2.2 → analytics_platform-0.3.0}/analytics_platform/posture.py +0 -0
  23. {analytics_platform-0.2.2 → analytics_platform-0.3.0}/analytics_platform/proxy.py +0 -0
  24. {analytics_platform-0.2.2 → analytics_platform-0.3.0}/analytics_platform/request_rules.py +0 -0
  25. {analytics_platform-0.2.2 → analytics_platform-0.3.0}/analytics_platform/resources.py +0 -0
  26. {analytics_platform-0.2.2 → analytics_platform-0.3.0}/analytics_platform.egg-info/dependency_links.txt +0 -0
  27. {analytics_platform-0.2.2 → analytics_platform-0.3.0}/analytics_platform.egg-info/top_level.txt +0 -0
  28. {analytics_platform-0.2.2 → analytics_platform-0.3.0}/setup.cfg +0 -0
  29. {analytics_platform-0.2.2 → analytics_platform-0.3.0}/tests/test_crawlers.py +0 -0
  30. {analytics_platform-0.2.2 → analytics_platform-0.3.0}/tests/test_firewall.py +0 -0
  31. {analytics_platform-0.2.2 → analytics_platform-0.3.0}/tests/test_proxy.py +0 -0
  32. {analytics_platform-0.2.2 → analytics_platform-0.3.0}/tests/test_request_rules.py +0 -0
  33. {analytics_platform-0.2.2 → analytics_platform-0.3.0}/tests/test_resources.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: analytics-platform
3
- Version: 0.2.2
3
+ Version: 0.3.0
4
4
  Summary: Firewall, error monitoring and server analytics for your Python server, run from your analytics dashboard.
5
5
  Author: steviec87
6
6
  License-Expression: MIT
@@ -158,8 +158,30 @@ except PaymentError as error:
158
158
  raise
159
159
  ```
160
160
 
161
+ Exceptions your code only logs: `errors={"capture_logging": True}` sends an
162
+ exception logged at ERROR or above (`logger.exception(...)`, or
163
+ `exc_info=True`); it is still logged. An exception is sent once however many
164
+ of these see it, and one logged inside a Django, Flask or ASGI request keeps
165
+ that request's route. A batch the collector couldn't take is tried once more
166
+ a few seconds later.
167
+
168
+ What still can't be sent waits in a file in the temp folder and is sent when
169
+ it can be, after a restart too; each error keeps the time it happened.
170
+
171
+ Background jobs, as a decorator or a `with` block (sync or async):
172
+
173
+ ```python
174
+ @platform.job("send-receipts")
175
+ def send_receipts(): ...
176
+ ```
177
+
178
+ Their errors carry the job's name as the route, a job that raises is sent
179
+ (status 500) and raised on, and each run is timed (Server speed → Jobs).
180
+ Several services on one site: `Platform(..., server_name="worker")`.
181
+
161
182
  Options: `server_name`, `root` (traceback paths relative to it),
162
- `capture_uncaught`, `before_send(event, error)` (change an error, or return
183
+ `capture_uncaught`, `capture_logging`, `spool_dir` (where unsent errors wait;
184
+ `False` for none), `before_send(event, error)` (change an error, or return
163
185
  `None` to drop it).
164
186
 
165
187
  ## Server analytics
@@ -132,8 +132,30 @@ except PaymentError as error:
132
132
  raise
133
133
  ```
134
134
 
135
+ Exceptions your code only logs: `errors={"capture_logging": True}` sends an
136
+ exception logged at ERROR or above (`logger.exception(...)`, or
137
+ `exc_info=True`); it is still logged. An exception is sent once however many
138
+ of these see it, and one logged inside a Django, Flask or ASGI request keeps
139
+ that request's route. A batch the collector couldn't take is tried once more
140
+ a few seconds later.
141
+
142
+ What still can't be sent waits in a file in the temp folder and is sent when
143
+ it can be, after a restart too; each error keeps the time it happened.
144
+
145
+ Background jobs, as a decorator or a `with` block (sync or async):
146
+
147
+ ```python
148
+ @platform.job("send-receipts")
149
+ def send_receipts(): ...
150
+ ```
151
+
152
+ Their errors carry the job's name as the route, a job that raises is sent
153
+ (status 500) and raised on, and each run is timed (Server speed → Jobs).
154
+ Several services on one site: `Platform(..., server_name="worker")`.
155
+
135
156
  Options: `server_name`, `root` (traceback paths relative to it),
136
- `capture_uncaught`, `before_send(event, error)` (change an error, or return
157
+ `capture_uncaught`, `capture_logging`, `spool_dir` (where unsent errors wait;
158
+ `False` for none), `before_send(event, error)` (change an error, or return
137
159
  `None` to drop it).
138
160
 
139
161
  ## Server analytics
@@ -14,9 +14,11 @@ A module is on with True (or its options as a dict) and off with False.
14
14
  No dependencies; Python 3.8+.
15
15
  """
16
16
  import asyncio
17
+ import functools
17
18
  import time
18
19
 
19
20
  from .core import Core, SESSION_HEADER, VERSION, header, request_target
21
+ from .errors import current_request
20
22
 
21
23
  __all__ = ["create_platform", "Platform", "PlatformMiddleware", "VERSION"]
22
24
 
@@ -78,7 +80,7 @@ class Platform:
78
80
  def __init__(self, site_id, secret, endpoint, trust_proxy=None, debug=False, firewall=True, errors=True, analytics=True,
79
81
  performance=True, crawlers=True, forms=True, headers=True, audit=True, flags=True, maintenance=True,
80
82
  inventory=True,
81
- posture=True, resources=True, proxy=True):
83
+ posture=True, resources=True, proxy=True, server_name=None):
82
84
  self.core = Core(site_id, secret, endpoint, debug)
83
85
  if not self.core.valid:
84
86
  print("[analytics-platform] site_id, secret and endpoint are required; nothing is sent and every request passes")
@@ -94,6 +96,10 @@ class Platform:
94
96
  opts = dict(setting) if isinstance(setting, dict) else {}
95
97
  if name == "firewall":
96
98
  opts.setdefault("trust_proxy", trust_proxy)
99
+ # server_name names this service for every module that sends one,
100
+ # so its errors, timings and resources can be told apart.
101
+ if server_name and name in ("errors", "performance", "crawlers", "resources"):
102
+ opts.setdefault("server_name", server_name)
97
103
  try:
98
104
  self._modules[name] = _load(name, self.core, opts)
99
105
  except Exception as error:
@@ -144,6 +150,19 @@ class Platform:
144
150
  if errors:
145
151
  errors.capture_exception(error, request=request, status=status, kind=kind)
146
152
 
153
+ def job(self, name):
154
+ """A background job (a queue consumer, a cron task), as a decorator or
155
+ a with block: its errors are sent with the job's name as their route,
156
+ and each run is timed (Health → Server speed → Jobs); a run that raises
157
+ counts as failed, and the exception is raised on.
158
+
159
+ @platform.job("send-receipts")
160
+ def send_receipts(): ...
161
+
162
+ with platform.job("nightly-import"): ...
163
+ """
164
+ return _Job(self, name)
165
+
147
166
  def login_failed(self, request_or_ip):
148
167
  """A failed login (bot protection): the address, or a Django, Flask or
149
168
  Starlette request (its address as trust_proxy says)."""
@@ -417,6 +436,53 @@ class _Headers:
417
436
  pass
418
437
 
419
438
 
439
+ class _Job:
440
+ """platform.job(name): a with block (sync or async) or a decorator."""
441
+
442
+ def __init__(self, platform, name):
443
+ self.platform = platform
444
+ self.name = name
445
+ self.route = "/(job) " + str(name or "job")[:180]
446
+ self._runs = []
447
+
448
+ def _fields(self):
449
+ return {"method": "JOB", "path": self.route, "route": self.route, "session_id": ""}
450
+
451
+ def __enter__(self):
452
+ self._runs.append((time.perf_counter(), current_request.set(self._fields)))
453
+ return self
454
+
455
+ def __exit__(self, kind, value, tb):
456
+ started, token = self._runs.pop()
457
+ current_request.reset(token)
458
+ if value is not None and not isinstance(value, Exception):
459
+ return False # cancelled or interrupted (CancelledError, KeyboardInterrupt): not a run that failed
460
+ if value is not None:
461
+ self.platform.capture_exception(value, status=500, kind="error", request=self._fields())
462
+ self.platform.time_request("JOB", self.route, 500 if value is not None else 200, started, self.route)
463
+ return False
464
+
465
+ async def __aenter__(self):
466
+ return self.__enter__()
467
+
468
+ async def __aexit__(self, kind, value, tb):
469
+ return self.__exit__(kind, value, tb)
470
+
471
+ def __call__(self, fn):
472
+ if asyncio.iscoroutinefunction(fn):
473
+ @functools.wraps(fn)
474
+ async def run_async(*args, **kwargs):
475
+ with _Job(self.platform, self.name):
476
+ return await fn(*args, **kwargs)
477
+ return run_async
478
+
479
+ @functools.wraps(fn)
480
+ def run(*args, **kwargs):
481
+ with _Job(self.platform, self.name):
482
+ return fn(*args, **kwargs)
483
+ return run
484
+
485
+
420
486
  class PlatformMiddleware:
421
487
  """ASGI middleware: app.add_middleware(PlatformMiddleware, platform=platform)."""
422
488
 
@@ -479,6 +545,13 @@ class PlatformMiddleware:
479
545
  message = dict(message, headers=list(message.get("headers") or []) + [(n.lower().encode(), v.encode("latin-1", "replace")) for n, v in extra] + remember)
480
546
  await send(message)
481
547
 
548
+ # Errors captured during the request (logged, or caught without the
549
+ # request) still know which request they came from. The router sets
550
+ # scope["route"] on the way in, so it is read when the error is.
551
+ token = current_request.set(lambda: {
552
+ "method": scope.get("method", ""), "path": scope.get("path", ""),
553
+ "route": getattr(scope.get("route"), "path", ""), "session_id": headers.get(SESSION_HEADER, ""),
554
+ })
482
555
  try:
483
556
  await self.app(scope, receive, timed_send)
484
557
  except Exception as error:
@@ -488,6 +561,7 @@ class PlatformMiddleware:
488
561
  })
489
562
  raise
490
563
  finally:
564
+ current_request.reset(token)
491
565
  # The router sets scope["route"] (FastAPI, Starlette) on the way in.
492
566
  self.platform.time_request(scope.get("method", ""), getattr(scope.get("route"), "path", "") or "",
493
567
  seen["status"], started, scope.get("path", ""))
@@ -16,7 +16,7 @@ import urllib.error
16
16
  import urllib.parse
17
17
  import urllib.request
18
18
 
19
- VERSION = "0.2.2"
19
+ VERSION = "0.3.0"
20
20
  TIMEOUT = 5
21
21
  MIN_SYNC = 30
22
22
  RETRY = 60
@@ -16,6 +16,7 @@ from django.conf import settings
16
16
  from django.http import HttpResponse
17
17
 
18
18
  from . import SESSION_HEADER, Platform
19
+ from .errors import current_request
19
20
 
20
21
  _platform = None
21
22
 
@@ -92,23 +93,34 @@ class PlatformMiddleware:
92
93
  for name, value in closed[1]:
93
94
  response[name] = value
94
95
  return response
95
- response = self.get_response(request)
96
+ # Errors captured during the request (logged, or caught without the
97
+ # request) still know which request they came from.
98
+ token = current_request.set(lambda: _request_fields(request))
99
+ try:
100
+ response = self.get_response(request)
101
+ finally:
102
+ current_request.reset(token)
96
103
  for name, value in self.platform.security_headers(response.headers.keys()):
97
104
  response[name] = value
98
105
  if closed:
99
106
  response["Set-Cookie"] = closed[1][0][1]
100
- match = getattr(request, "resolver_match", None)
101
- route = getattr(match, "route", "") or ""
102
- self.platform.time_request(request.method, ("/" + route.lstrip("^").rstrip("$").lstrip("/")) if route else "",
103
- response.status_code, started, request.path)
107
+ self.platform.time_request(request.method, _route(request), response.status_code, started, request.path)
104
108
  self.platform.record_crawler(headers.get("user-agent", ""), request.path, response.status_code, started,
105
109
  response.headers.get("Content-Length") or 0, ip)
106
110
  return response
107
111
 
108
112
  def process_exception(self, request, exception):
109
- match = getattr(request, "resolver_match", None)
110
- self.platform.capture_exception(exception, status=500, kind="error", request={
111
- "method": request.method, "path": request.path, "route": getattr(match, "route", "") or "",
112
- "session_id": request.headers.get(SESSION_HEADER, ""),
113
- })
113
+ self.platform.capture_exception(exception, status=500, kind="error", request=_request_fields(request))
114
114
  return None
115
+
116
+
117
+ # The URL pattern, as Server speed names it, so an error's route and the
118
+ # route's timings match.
119
+ def _route(request):
120
+ route = getattr(getattr(request, "resolver_match", None), "route", "") or ""
121
+ return ("/" + route.lstrip("^").rstrip("$").lstrip("/")) if route else ""
122
+
123
+
124
+ def _request_fields(request):
125
+ return {"method": request.method, "path": request.path, "route": _route(request),
126
+ "session_id": request.headers.get(SESSION_HEADER, "")}
@@ -0,0 +1,322 @@
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; capture_logging sends the exceptions your code logs. An
7
+ error is sent once however many of those see it. Errors are sent in small
8
+ batches, a batch the collector couldn't take is tried once more, and sending
9
+ never raises. What still can't be sent (the collector is away, or the process
10
+ is crashing) waits in a file and is sent once it can be, after a restart too;
11
+ each error carries when it happened.
12
+ """
13
+ import contextvars
14
+ import hashlib
15
+ import json
16
+ import logging
17
+ import os
18
+ import tempfile
19
+ import platform as host
20
+ import re
21
+ import socket
22
+ import sys
23
+ import threading
24
+ import time
25
+ import traceback
26
+ from datetime import datetime, timezone
27
+
28
+ from .core import SESSION_HEADER, Periodic
29
+ from .performance import normalize_route
30
+
31
+ BATCH = 10
32
+ QUEUE_MAX = 100
33
+ FLUSH_EVERY = 2
34
+ # A batch the collector couldn't take (unreachable, 429, 5xx) is sent once
35
+ # more after this many seconds.
36
+ RETRY_AFTER = 2
37
+ SEEN = "_analytics_platform_sent"
38
+ # The file of errors waiting to be sent: tried again this often (seconds), and
39
+ # kept under this size (past it, new ones are dropped rather than filling a disk).
40
+ RESEND_EVERY = 60
41
+ SPOOL_BYTES = 1024 * 1024
42
+
43
+ # The request being handled, set by the framework integrations: a callable
44
+ # returning {"method", "path", "route", "session_id"}, so an error captured
45
+ # without a request (logged, or caught deep in your code) still gets its route.
46
+ current_request = contextvars.ContextVar("analytics_platform_request", default=None)
47
+
48
+
49
+ def _request_fields():
50
+ fields = current_request.get()
51
+ if fields is None:
52
+ flask = sys.modules.get("flask")
53
+ if flask is not None and flask.has_request_context():
54
+ rule = getattr(flask.request, "url_rule", None)
55
+ return {"method": flask.request.method, "path": flask.request.path, "route": getattr(rule, "rule", ""),
56
+ "session_id": flask.request.headers.get(SESSION_HEADER, "")}
57
+ return None
58
+ try:
59
+ return fields()
60
+ except Exception:
61
+ return None
62
+
63
+
64
+ def _failed(res):
65
+ return res is None or res.status == 429 or res.status >= 500
66
+
67
+
68
+ class _LogHandler(logging.Handler):
69
+ """Sends the exception of a log record at ERROR or above (logger.exception,
70
+ or exc_info=True). A record without an exception isn't sent: it has no
71
+ traceback to group it by."""
72
+
73
+ def __init__(self, errors):
74
+ super().__init__(logging.ERROR)
75
+ self.errors = errors
76
+
77
+ def emit(self, record):
78
+ exc = record.exc_info[1] if record.exc_info else None
79
+ if isinstance(exc, BaseException) and not record.name.startswith("analytics_platform"):
80
+ self.errors.capture_exception(exc, kind="logged")
81
+
82
+
83
+ class Errors:
84
+ """Options: server_name (default the host name), release (which build this
85
+ is; default the ANALYTICS_RELEASE environment variable), root (paths in tracebacks
86
+ are made relative to it; default the working directory), capture_uncaught
87
+ (default True), capture_logging (default False: exceptions logged at
88
+ ERROR or above through the logging module are sent too),
89
+ before_send(event, error) to change an event or drop it (return None),
90
+ spool_dir (where errors that couldn't be sent wait; default the system's
91
+ temp folder, False to keep none)."""
92
+
93
+ def __init__(self, core, server_name=None, root=None, release=None, capture_uncaught=True, before_send=None,
94
+ capture_logging=False, spool_dir=None, **_):
95
+ self.core = core
96
+ self.server_name = str(server_name or socket.gethostname())[:100]
97
+ self.root = str(root or os.getcwd()).rstrip("/\\")
98
+ # Which build this is, for "introduced in" in the Errors report.
99
+ self.release = re.sub(r"[^\w.+\-]", "", str(release or os.environ.get("ANALYTICS_RELEASE", "")).strip())[:64]
100
+ self.before_send = before_send
101
+ self.queue = []
102
+ self.lock = threading.Lock()
103
+ # Named after the site and its secret, not the site id alone, so the
104
+ # folder's listing tells nobody which site it is.
105
+ self.spool_file = None
106
+ if spool_dir is not False and core.valid:
107
+ name = hashlib.sha256(("%s|%s" % (core.site_id, core.secret)).encode()).hexdigest()[:16]
108
+ self.spool_file = os.path.join(str(spool_dir or tempfile.gettempdir()), "analytics-errors-%s.jsonl" % name)
109
+ self._last_resend = 0.0
110
+ # One resend at a time (threads share the file), and what it is still
111
+ # sending, written back if the process crashes meanwhile.
112
+ self._resend_lock = threading.Lock()
113
+ self._resending = []
114
+ self.job = Periodic(self._flush_job, FLUSH_EVERY) if core.valid else None
115
+ self._previous = None
116
+ if capture_uncaught:
117
+ self._previous = (sys.excepthook, threading.excepthook)
118
+ sys.excepthook = self._excepthook
119
+ threading.excepthook = self._thread_excepthook
120
+ self._log_handler = None
121
+ if capture_logging:
122
+ self._log_handler = _LogHandler(self)
123
+ logging.getLogger().addHandler(self._log_handler)
124
+
125
+ def _excepthook(self, kind, value, tb):
126
+ self.capture_exception(value, kind="error")
127
+ # The file waits for the next start rather than delaying the exit.
128
+ self.flush(resend=False)
129
+ self._keep_resending()
130
+ self._previous[0](kind, value, tb)
131
+
132
+ def _thread_excepthook(self, args):
133
+ if args.exc_value is not None:
134
+ self.capture_exception(args.exc_value, kind="error")
135
+ self.flush(resend=False)
136
+ self._previous[1](args)
137
+
138
+ def _keep_resending(self):
139
+ """The process is exiting: what a resend hasn't sent yet goes back in the file."""
140
+ with self.lock:
141
+ left, self._resending = self._resending, []
142
+ self._spool(left)
143
+
144
+ def _relative(self, stack):
145
+ return stack.replace(self.root + os.sep, "").replace(self.root + "/", "") if self.root else stack
146
+
147
+ def capture_exception(self, error, request=None, status=0, kind="handled"):
148
+ """request: {"method", "path", "route", "session_id"} (the integrations fill it in)."""
149
+ if not self.core.is_on("errors"):
150
+ return
151
+ try:
152
+ is_error = isinstance(error, BaseException)
153
+ if request is None:
154
+ request = _request_fields()
155
+ # The framework, your handler and logging can all see one error:
156
+ # sent once, and a later capture fills in the event while it waits.
157
+ if is_error and getattr(error, SEEN, None) is not None:
158
+ self._upgrade(error, getattr(error, SEEN), request, status, kind)
159
+ return
160
+ stack = "".join(traceback.format_exception(type(error), error, error.__traceback__)) if is_error else ""
161
+ event = {
162
+ "message": str(error) or (type(error).__name__ if is_error else "Unknown error"),
163
+ "error_type": type(error).__name__ if is_error else "Error",
164
+ "stack": self._relative(stack),
165
+ "kind": kind,
166
+ "status_code": int(status or 0),
167
+ "runtime": "Python",
168
+ "runtime_version": host.python_version(),
169
+ "os": sys.platform,
170
+ "server_name": self.server_name,
171
+ "release": self.release,
172
+ # When it happened: a crash sent after a restart keeps its own time.
173
+ "at": datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z"),
174
+ }
175
+ event.update({k: str(v or "") for k, v in (request or {}).items() if k in ("method", "path", "route", "session_id")})
176
+ if event.get("path"):
177
+ event["path"] = event["path"].split("?")[0]
178
+ # Named as Server speed names the request, so the two match.
179
+ if not event.get("route"):
180
+ event["route"] = normalize_route(event["path"])
181
+ if self.before_send:
182
+ event = self.before_send(event, error)
183
+ if not event:
184
+ return
185
+ with self.lock:
186
+ if len(self.queue) >= QUEUE_MAX:
187
+ self.core.log("errors", "queue full, error dropped")
188
+ return
189
+ self.queue.append(event)
190
+ full = len(self.queue) >= BATCH
191
+ if is_error:
192
+ try:
193
+ setattr(error, SEEN, event)
194
+ except Exception:
195
+ pass
196
+ if full and self.job:
197
+ self.job.run_now()
198
+ except Exception as failure:
199
+ self.core.log("errors", "could not capture error:", failure)
200
+
201
+ def _upgrade(self, error, waiting, request, status, kind):
202
+ """An exception logged and then raised on is captured first as
203
+ "logged", with no status; the framework's capture that follows knows
204
+ the status, the kind and the request."""
205
+ if kind == "logged":
206
+ return
207
+ with self.lock:
208
+ index = next((i for i, queued in enumerate(self.queue) if queued is waiting), -1)
209
+ if index == -1:
210
+ return
211
+ event = dict(waiting, kind=kind if waiting.get("kind") == "logged" else waiting.get("kind"),
212
+ status_code=waiting.get("status_code") or int(status or 0))
213
+ for key in ("method", "path", "route", "session_id"):
214
+ if not event.get(key) and (request or {}).get(key):
215
+ event[key] = str(request[key])
216
+ if event.get("path"):
217
+ event["path"] = event["path"].split("?")[0]
218
+ if self.before_send:
219
+ event = self.before_send(event, error)
220
+ if event:
221
+ self.queue[index] = event
222
+ setattr(error, SEEN, event)
223
+ else:
224
+ del self.queue[index]
225
+
226
+ def flush(self, resend=True):
227
+ with self.lock:
228
+ waiting, self.queue = self.queue, []
229
+ sent = True
230
+ for i in range(0, len(waiting), BATCH):
231
+ body = {"errors": waiting[i:i + BATCH]}
232
+ res = self.core.send("errors", "/events/server-errors", body=body)
233
+ if _failed(res):
234
+ time.sleep(RETRY_AFTER)
235
+ res = self.core.send("errors", "/events/server-errors", body=body)
236
+ if _failed(res):
237
+ sent = False
238
+ self._spool(body["errors"])
239
+ # Not straight after the collector failed this flush: it would only
240
+ # wait out another timeout.
241
+ if resend and sent and self.spool_file and time.time() - self._last_resend >= RESEND_EVERY:
242
+ self.resend()
243
+
244
+ def _spool(self, events):
245
+ """Appends events to the file. Credentials are added when sent, never written down."""
246
+ if not self.spool_file or not events:
247
+ return
248
+ try:
249
+ if os.path.exists(self.spool_file) and os.path.getsize(self.spool_file) > SPOOL_BYTES:
250
+ self.core.log("errors", "waiting errors file is full, errors dropped")
251
+ return
252
+ fd = os.open(self.spool_file, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600)
253
+ with os.fdopen(fd, "a", encoding="utf-8") as out:
254
+ out.write("".join(json.dumps(event) + "\n" for event in events))
255
+ except Exception as failure:
256
+ self.core.log("errors", "could not keep errors to send later:", failure)
257
+
258
+ def resend(self):
259
+ """Sends what earlier attempts (or an earlier run) couldn't. Taken by
260
+ renaming the file first, so two processes never send the same errors."""
261
+ self._last_resend = time.time()
262
+ if not self.spool_file or not self._resend_lock.acquire(blocking=False):
263
+ return
264
+ try:
265
+ self._resend()
266
+ finally:
267
+ self._resend_lock.release()
268
+
269
+ def _resend(self):
270
+ taken = "%s.%d.%d.sending" % (self.spool_file, os.getpid(), threading.get_ident())
271
+ try:
272
+ os.replace(self.spool_file, taken)
273
+ except OSError:
274
+ return # nothing waiting
275
+ events = []
276
+ try:
277
+ with open(taken, encoding="utf-8") as waiting:
278
+ for line in waiting:
279
+ try:
280
+ event = json.loads(line)
281
+ except ValueError:
282
+ continue
283
+ if isinstance(event, dict):
284
+ events.append(event)
285
+ finally:
286
+ try:
287
+ os.remove(taken)
288
+ except OSError:
289
+ pass
290
+ with self.lock:
291
+ self._resending = events
292
+ while True:
293
+ with self.lock:
294
+ if self._resending is not events or not events:
295
+ break # all sent, or written back by a crash
296
+ batch = events[:BATCH]
297
+ if _failed(self.core.send("errors", "/events/server-errors", body={"errors": batch})):
298
+ with self.lock:
299
+ left = events if self._resending is events else []
300
+ self._resending = []
301
+ self._spool(left)
302
+ return
303
+ with self.lock:
304
+ del events[:len(batch)]
305
+ with self.lock:
306
+ if self._resending is events:
307
+ self._resending = []
308
+
309
+ def _flush_job(self):
310
+ self.flush()
311
+ return FLUSH_EVERY
312
+
313
+ def close(self):
314
+ if self.job:
315
+ self.job.stop()
316
+ if self._previous:
317
+ sys.excepthook, threading.excepthook = self._previous
318
+ self._previous = None
319
+ if self._log_handler:
320
+ logging.getLogger().removeHandler(self._log_handler)
321
+ self._log_handler = None
322
+ self.flush()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: analytics-platform
3
- Version: 0.2.2
3
+ Version: 0.3.0
4
4
  Summary: Firewall, error monitoring and server analytics for your Python server, run from your analytics dashboard.
5
5
  Author: steviec87
6
6
  License-Expression: MIT
@@ -158,8 +158,30 @@ except PaymentError as error:
158
158
  raise
159
159
  ```
160
160
 
161
+ Exceptions your code only logs: `errors={"capture_logging": True}` sends an
162
+ exception logged at ERROR or above (`logger.exception(...)`, or
163
+ `exc_info=True`); it is still logged. An exception is sent once however many
164
+ of these see it, and one logged inside a Django, Flask or ASGI request keeps
165
+ that request's route. A batch the collector couldn't take is tried once more
166
+ a few seconds later.
167
+
168
+ What still can't be sent waits in a file in the temp folder and is sent when
169
+ it can be, after a restart too; each error keeps the time it happened.
170
+
171
+ Background jobs, as a decorator or a `with` block (sync or async):
172
+
173
+ ```python
174
+ @platform.job("send-receipts")
175
+ def send_receipts(): ...
176
+ ```
177
+
178
+ Their errors carry the job's name as the route, a job that raises is sent
179
+ (status 500) and raised on, and each run is timed (Server speed → Jobs).
180
+ Several services on one site: `Platform(..., server_name="worker")`.
181
+
161
182
  Options: `server_name`, `root` (traceback paths relative to it),
162
- `capture_uncaught`, `before_send(event, error)` (change an error, or return
183
+ `capture_uncaught`, `capture_logging`, `spool_dir` (where unsent errors wait;
184
+ `False` for none), `before_send(event, error)` (change an error, or return
163
185
  `None` to drop it).
164
186
 
165
187
  ## Server analytics
@@ -23,6 +23,7 @@ analytics_platform.egg-info/SOURCES.txt
23
23
  analytics_platform.egg-info/dependency_links.txt
24
24
  analytics_platform.egg-info/top_level.txt
25
25
  tests/test_crawlers.py
26
+ tests/test_errors.py
26
27
  tests/test_firewall.py
27
28
  tests/test_proxy.py
28
29
  tests/test_request_rules.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "analytics-platform"
7
- version = "0.2.2"
7
+ version = "0.3.0"
8
8
  description = "Firewall, error monitoring and server analytics for your Python server, run from your analytics dashboard."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.8"
@@ -0,0 +1,253 @@
1
+ """The errors module: an error is sent once however many handlers see it,
2
+ exceptions logged through logging are sent when asked, a request's route
3
+ follows errors captured without it, and a failed batch is tried once more.
4
+
5
+ python3 -m unittest discover -s tests
6
+ """
7
+ import json
8
+ import logging
9
+ import os
10
+ import sys
11
+ import unittest
12
+
13
+ sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
14
+
15
+ from analytics_platform import errors as mod # noqa: E402
16
+ from analytics_platform.errors import Errors, current_request # noqa: E402
17
+
18
+
19
+ class Answer:
20
+ def __init__(self, status):
21
+ self.status = status
22
+
23
+
24
+ class FakeCore:
25
+ valid = False # no background job: the tests flush by hand
26
+
27
+ def __init__(self, answers=None):
28
+ self.answers = list(answers or [])
29
+ self.sent = []
30
+
31
+ def is_on(self, name):
32
+ return True
33
+
34
+ def log(self, *args):
35
+ pass
36
+
37
+ def send(self, module, path, body=None, **_):
38
+ self.sent.append(body["errors"])
39
+ status = self.answers.pop(0) if self.answers else 202
40
+ return None if status is None else Answer(status)
41
+
42
+
43
+ def events(core):
44
+ return [event for batch in core.sent for event in batch]
45
+
46
+
47
+ class ErrorsTest(unittest.TestCase):
48
+ def setUp(self):
49
+ self.retry = mod.RETRY_AFTER
50
+ mod.RETRY_AFTER = 0
51
+
52
+ def tearDown(self):
53
+ mod.RETRY_AFTER = self.retry
54
+
55
+ def test_one_error_is_sent_once(self):
56
+ core = FakeCore()
57
+ errors = Errors(core, capture_uncaught=False)
58
+ error = ValueError("payment failed")
59
+ errors.capture_exception(error, status=500, kind="error")
60
+ errors.capture_exception(error)
61
+ errors.capture_exception(ValueError("payment failed"))
62
+ errors.close()
63
+ self.assertEqual(len(events(core)), 2)
64
+
65
+ def test_capture_logging_sends_logged_exceptions_only_when_on(self):
66
+ logger = logging.getLogger("shop.checkout")
67
+ quiet = logging.NullHandler() # keeps logging's last-resort printer out of the test output
68
+ logger.addHandler(quiet)
69
+ try:
70
+ core = FakeCore()
71
+ off = Errors(core, capture_uncaught=False)
72
+ try:
73
+ raise RuntimeError("off")
74
+ except RuntimeError:
75
+ logger.exception("not sent")
76
+ off.close()
77
+ self.assertEqual(core.sent, [])
78
+
79
+ on = Errors(core, capture_uncaught=False, capture_logging=True)
80
+ try:
81
+ raise RuntimeError("card declined")
82
+ except RuntimeError:
83
+ logger.exception("charge failed")
84
+ logger.error("plain text is not sent")
85
+ on.close()
86
+ sent = events(core)
87
+ self.assertEqual(len(sent), 1)
88
+ self.assertEqual(sent[0]["message"], "card declined")
89
+ self.assertEqual(sent[0]["kind"], "logged")
90
+ self.assertNotIn(on._log_handler, logging.getLogger().handlers)
91
+ finally:
92
+ logger.removeHandler(quiet)
93
+
94
+ def test_the_current_request_fills_in_an_error_captured_without_one(self):
95
+ core = FakeCore()
96
+ errors = Errors(core, capture_uncaught=False)
97
+ token = current_request.set(lambda: {"method": "POST", "path": "/orders/12", "route": "/orders/<int:id>"})
98
+ try:
99
+ errors.capture_exception(ValueError("deep inside"))
100
+ finally:
101
+ current_request.reset(token)
102
+ errors.capture_exception(ValueError("outside"))
103
+ errors.close()
104
+ inside, outside = events(core)
105
+ self.assertEqual(inside["route"], "/orders/<int:id>")
106
+ self.assertEqual(inside["method"], "POST")
107
+ self.assertNotIn("route", outside)
108
+
109
+ def test_a_failed_batch_is_sent_once_more_and_only_once(self):
110
+ for answers, expected in (([503, 503], 2), ([None, 202], 2), ([400], 1)):
111
+ core = FakeCore(answers)
112
+ errors = Errors(core, capture_uncaught=False)
113
+ errors.capture_exception(ValueError("x"))
114
+ errors.flush()
115
+ self.assertEqual(len(core.sent), expected, answers)
116
+
117
+
118
+ def test_an_exception_logged_then_raised_keeps_the_frameworks_status_and_request(self):
119
+ logger = logging.getLogger("shop.pay")
120
+ quiet = logging.NullHandler()
121
+ logger.addHandler(quiet)
122
+ try:
123
+ core = FakeCore()
124
+ errors = Errors(core, capture_uncaught=False, capture_logging=True)
125
+ try:
126
+ raise RuntimeError("charge failed")
127
+ except RuntimeError as error:
128
+ logger.exception("charge failed")
129
+ errors.capture_exception(error, status=500, kind="error",
130
+ request={"method": "POST", "path": "/pay", "route": "/pay"})
131
+ errors.close()
132
+ sent = events(core)
133
+ self.assertEqual(len(sent), 1)
134
+ self.assertEqual((sent[0]["kind"], sent[0]["status_code"], sent[0]["route"]), ("error", 500, "/pay"))
135
+ finally:
136
+ logger.removeHandler(quiet)
137
+
138
+ def test_without_a_route_an_error_is_named_as_server_speed_names_the_request(self):
139
+ core = FakeCore()
140
+ errors = Errors(core, capture_uncaught=False)
141
+ errors.capture_exception(ValueError("x"), request={"method": "GET", "path": "/orders/123/items?x=1"})
142
+ errors.close()
143
+ self.assertEqual(events(core)[0]["route"], "/orders/:id/items")
144
+
145
+ def test_errors_the_collector_could_not_take_wait_in_a_file_and_are_sent_later(self):
146
+ import tempfile
147
+ import shutil
148
+ folder = tempfile.mkdtemp()
149
+ try:
150
+ core = FakeCore([503, 503])
151
+ core.valid, core.site_id, core.secret = True, "a1b2c3d4e5f6a1b2c3d4e5f6", "s"
152
+ errors = Errors(core, capture_uncaught=False, spool_dir=folder)
153
+ errors.job.stop() # flushed by hand
154
+ errors._last_resend = 10 ** 12 # no resend inside this flush
155
+ errors.capture_exception(ValueError("collector away"))
156
+ errors.flush()
157
+ files = os.listdir(folder)
158
+ self.assertEqual(len(files), 1)
159
+ kept = open(os.path.join(folder, files[0])).read()
160
+ self.assertIn("collector away", kept)
161
+ self.assertNotIn("a1b2c3d4e5f6a1b2c3d4e5f6", kept + files[0])
162
+ self.assertRegex(kept, r'"at": "\d{4}-\d\d-\d\dT[^"]+Z"')
163
+ core.sent = []
164
+ errors.resend()
165
+ self.assertEqual(events(core)[0]["message"], "collector away")
166
+ self.assertEqual(os.listdir(folder), [])
167
+ finally:
168
+ shutil.rmtree(folder, ignore_errors=True)
169
+
170
+ def test_a_job_names_its_errors_and_sends_the_one_it_raises(self):
171
+ from analytics_platform import _Job
172
+
173
+ class FakePlatform:
174
+ def __init__(self, errors):
175
+ self.errors, self.timed = errors, []
176
+
177
+ def capture_exception(self, error, **kwargs):
178
+ self.errors.capture_exception(error, **kwargs)
179
+
180
+ def time_request(self, method, route, status, started, path=""):
181
+ self.timed.append((method, route, status))
182
+
183
+ core = FakeCore()
184
+ errors = Errors(core, capture_uncaught=False)
185
+ platform = FakePlatform(errors)
186
+
187
+ @_Job(platform, "receipts")
188
+ def receipts():
189
+ errors.capture_exception(ValueError("one receipt failed"))
190
+ raise RuntimeError("the job failed")
191
+
192
+ with self.assertRaises(RuntimeError):
193
+ receipts()
194
+ errors.close()
195
+ inside, raised = events(core)
196
+ self.assertEqual((inside["method"], inside["route"]), ("JOB", "/(job) receipts"))
197
+ self.assertEqual(raised["status_code"], 500)
198
+ self.assertEqual(platform.timed, [("JOB", "/(job) receipts", 500)])
199
+
200
+ # Cancelled, not failed: neither sent nor timed.
201
+ import asyncio
202
+ with self.assertRaises(asyncio.CancelledError):
203
+ with _Job(platform, "receipts"):
204
+ raise asyncio.CancelledError()
205
+ self.assertEqual(len(platform.timed), 1)
206
+
207
+ def spooling(self, answers):
208
+ import tempfile
209
+ import shutil
210
+ folder = tempfile.mkdtemp()
211
+ self.addCleanup(shutil.rmtree, folder, True)
212
+ core = FakeCore(answers)
213
+ core.valid, core.site_id, core.secret = True, "a1b2c3d4e5f6a1b2c3d4e5f6", "s"
214
+ errors = Errors(core, capture_uncaught=False, spool_dir=folder)
215
+ errors.job.stop() # flushed by hand
216
+ return core, errors
217
+
218
+ def test_a_flush_whose_own_sends_failed_does_not_resend_straight_after(self):
219
+ core, errors = self.spooling([503, 503])
220
+ errors.capture_exception(ValueError("collector away"))
221
+ errors.flush()
222
+ self.assertEqual(len(core.sent), 2, "the send and its one retry, no resend of the file")
223
+ self.assertTrue(os.path.exists(errors.spool_file))
224
+
225
+ def test_one_resend_at_a_time(self):
226
+ core, errors = self.spooling([503, 503])
227
+ errors._last_resend = 10 ** 12
228
+ errors.capture_exception(ValueError("waiting"))
229
+ errors.flush()
230
+ core.sent = []
231
+ with errors._resend_lock:
232
+ errors.resend() # another thread is resending
233
+ self.assertEqual(core.sent, [])
234
+ self.assertTrue(os.path.exists(errors.spool_file))
235
+
236
+ def test_errors_being_resent_when_the_process_crashes_go_back_in_the_file(self):
237
+ core, errors = self.spooling([])
238
+ errors._spool([{"message": "e%d" % i} for i in range(12)])
239
+ calls = []
240
+
241
+ def send(module, path, body=None, **_):
242
+ calls.append(body["errors"])
243
+ if len(calls) == 1:
244
+ return Answer(202)
245
+ errors._keep_resending() # the crash, while the second batch is out
246
+ return None
247
+ core.send = send
248
+ errors.resend()
249
+ kept = open(errors.spool_file).read().splitlines()
250
+ self.assertEqual([json.loads(line)["message"] for line in kept], ["e10", "e11"], "once, and not what was sent")
251
+
252
+ if __name__ == "__main__":
253
+ unittest.main()
@@ -1,113 +0,0 @@
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()