analytics-platform 0.2.1__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.1 → analytics_platform-0.3.0}/PKG-INFO +25 -3
  2. {analytics_platform-0.2.1 → analytics_platform-0.3.0}/README.md +24 -2
  3. {analytics_platform-0.2.1 → analytics_platform-0.3.0}/analytics_platform/__init__.py +93 -5
  4. {analytics_platform-0.2.1 → analytics_platform-0.3.0}/analytics_platform/core.py +26 -1
  5. {analytics_platform-0.2.1 → analytics_platform-0.3.0}/analytics_platform/django.py +25 -11
  6. analytics_platform-0.3.0/analytics_platform/errors.py +322 -0
  7. {analytics_platform-0.2.1 → analytics_platform-0.3.0}/analytics_platform/firewall.py +3 -2
  8. {analytics_platform-0.2.1 → analytics_platform-0.3.0}/analytics_platform/proxy.py +1 -0
  9. {analytics_platform-0.2.1 → analytics_platform-0.3.0}/analytics_platform/request_rules.py +96 -17
  10. {analytics_platform-0.2.1 → analytics_platform-0.3.0}/analytics_platform/resources.py +22 -3
  11. {analytics_platform-0.2.1 → analytics_platform-0.3.0}/analytics_platform.egg-info/PKG-INFO +25 -3
  12. {analytics_platform-0.2.1 → analytics_platform-0.3.0}/analytics_platform.egg-info/SOURCES.txt +2 -0
  13. {analytics_platform-0.2.1 → analytics_platform-0.3.0}/pyproject.toml +1 -1
  14. analytics_platform-0.3.0/tests/test_errors.py +253 -0
  15. analytics_platform-0.3.0/tests/test_firewall.py +56 -0
  16. {analytics_platform-0.2.1 → analytics_platform-0.3.0}/tests/test_request_rules.py +32 -0
  17. {analytics_platform-0.2.1 → analytics_platform-0.3.0}/tests/test_resources.py +35 -0
  18. analytics_platform-0.2.1/analytics_platform/errors.py +0 -113
  19. {analytics_platform-0.2.1 → analytics_platform-0.3.0}/LICENSE +0 -0
  20. {analytics_platform-0.2.1 → analytics_platform-0.3.0}/analytics_platform/analytics.py +0 -0
  21. {analytics_platform-0.2.1 → analytics_platform-0.3.0}/analytics_platform/audit.py +0 -0
  22. {analytics_platform-0.2.1 → analytics_platform-0.3.0}/analytics_platform/crawlers.py +0 -0
  23. {analytics_platform-0.2.1 → analytics_platform-0.3.0}/analytics_platform/flags.py +0 -0
  24. {analytics_platform-0.2.1 → analytics_platform-0.3.0}/analytics_platform/forms.py +0 -0
  25. {analytics_platform-0.2.1 → analytics_platform-0.3.0}/analytics_platform/inventory.py +0 -0
  26. {analytics_platform-0.2.1 → analytics_platform-0.3.0}/analytics_platform/maintenance.py +0 -0
  27. {analytics_platform-0.2.1 → analytics_platform-0.3.0}/analytics_platform/performance.py +0 -0
  28. {analytics_platform-0.2.1 → analytics_platform-0.3.0}/analytics_platform/posture.py +0 -0
  29. {analytics_platform-0.2.1 → analytics_platform-0.3.0}/analytics_platform.egg-info/dependency_links.txt +0 -0
  30. {analytics_platform-0.2.1 → analytics_platform-0.3.0}/analytics_platform.egg-info/top_level.txt +0 -0
  31. {analytics_platform-0.2.1 → analytics_platform-0.3.0}/setup.cfg +0 -0
  32. {analytics_platform-0.2.1 → analytics_platform-0.3.0}/tests/test_crawlers.py +0 -0
  33. {analytics_platform-0.2.1 → analytics_platform-0.3.0}/tests/test_proxy.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: analytics-platform
3
- Version: 0.2.1
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
@@ -103,7 +103,7 @@ create_platform(site_id=..., secret=..., endpoint=...,
103
103
  analytics=False)
104
104
  ```
105
105
 
106
- **Switch modules off in the dashboard** (Security → Settings → Server package) without a
106
+ **Switch modules off in the dashboard** (Security → Server package) without a
107
107
  deploy: the package checks about once a minute. Modules are independent: one
108
108
  failing to start, or the collector being down, never stops the others, and
109
109
  never slows your requests. Background work runs in daemon threads, which start
@@ -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
@@ -77,7 +77,7 @@ create_platform(site_id=..., secret=..., endpoint=...,
77
77
  analytics=False)
78
78
  ```
79
79
 
80
- **Switch modules off in the dashboard** (Security → Settings → Server package) without a
80
+ **Switch modules off in the dashboard** (Security → Server package) without a
81
81
  deploy: the package checks about once a minute. Modules are independent: one
82
82
  failing to start, or the collector being down, never stops the others, and
83
83
  never slows your requests. Background work runs in daemon threads, which start
@@ -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
- from .core import Core, SESSION_HEADER, VERSION, header
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)."""
@@ -286,7 +305,8 @@ class Platform:
286
305
 
287
306
  def blocked(self, remote, headers, method, path):
288
307
  """(status, body, headers) when the firewall turns the request away,
289
- else None. headers: a dict with lowercase names."""
308
+ else None. headers: a dict with lowercase names. path: the request
309
+ target as sent, query string and all, not yet decoded (request_target)."""
290
310
  firewall = self._modules.get("firewall")
291
311
  if not firewall:
292
312
  return None
@@ -321,7 +341,7 @@ class Platform:
321
341
  start_response("%d " % status, list(extra))
322
342
  return [body]
323
343
  blocked = platform.blocked(environ.get("REMOTE_ADDR", ""), headers, environ.get("REQUEST_METHOD", ""),
324
- environ.get("PATH_INFO", ""))
344
+ _wsgi_target(environ))
325
345
  if blocked:
326
346
  status, body, extra = blocked
327
347
  start_response("%d %s" % (status, body.decode()), extra)
@@ -387,6 +407,17 @@ class Platform:
387
407
  return PlatformMiddleware(app, platform=self)
388
408
 
389
409
 
410
+ def _wsgi_target(environ):
411
+ # gunicorn keeps the target as sent (RAW_URI), uWSGI and mod_wsgi too
412
+ # (REQUEST_URI); PATH_INFO is decoded, and PEP 3333 hands its bytes over as latin-1.
413
+ path = environ.get("SCRIPT_NAME", "") + environ.get("PATH_INFO", "")
414
+ try:
415
+ path = path.encode("latin-1")
416
+ except UnicodeEncodeError:
417
+ pass
418
+ return request_target(path, environ.get("QUERY_STRING", ""), environ.get("RAW_URI") or environ.get("REQUEST_URI"))
419
+
420
+
390
421
  def _wsgi_body(environ):
391
422
  try:
392
423
  length = int(environ.get("CONTENT_LENGTH") or 0)
@@ -405,6 +436,53 @@ class _Headers:
405
436
  pass
406
437
 
407
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
+
408
486
  class PlatformMiddleware:
409
487
  """ASGI middleware: app.add_middleware(PlatformMiddleware, platform=platform)."""
410
488
 
@@ -439,7 +517,9 @@ class PlatformMiddleware:
439
517
  "headers": [(k.lower().encode(), str(v).encode()) for k, v in extra]})
440
518
  await send({"type": "http.response.body", "body": out})
441
519
  return
442
- blocked = self.platform.blocked(client[0] or "", headers, scope.get("method", ""), scope.get("path", ""))
520
+ # raw_path is the path as sent; servers that leave it out get path encoded again.
521
+ target = request_target(scope.get("path", ""), scope.get("query_string", b""), scope.get("raw_path"))
522
+ blocked = self.platform.blocked(client[0] or "", headers, scope.get("method", ""), target)
443
523
  if blocked:
444
524
  status, body, extra = blocked
445
525
  await send({"type": "http.response.start", "status": status,
@@ -465,6 +545,13 @@ class PlatformMiddleware:
465
545
  message = dict(message, headers=list(message.get("headers") or []) + [(n.lower().encode(), v.encode("latin-1", "replace")) for n, v in extra] + remember)
466
546
  await send(message)
467
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
+ })
468
555
  try:
469
556
  await self.app(scope, receive, timed_send)
470
557
  except Exception as error:
@@ -474,6 +561,7 @@ class PlatformMiddleware:
474
561
  })
475
562
  raise
476
563
  finally:
564
+ current_request.reset(token)
477
565
  # The router sets scope["route"] (FastAPI, Starlette) on the way in.
478
566
  self.platform.time_request(scope.get("method", ""), getattr(scope.get("route"), "path", "") or "",
479
567
  seen["status"], started, scope.get("path", ""))
@@ -13,9 +13,10 @@ import os
13
13
  import re
14
14
  import threading
15
15
  import urllib.error
16
+ import urllib.parse
16
17
  import urllib.request
17
18
 
18
- VERSION = "0.1.0"
19
+ VERSION = "0.3.0"
19
20
  TIMEOUT = 5
20
21
  MIN_SYNC = 30
21
22
  RETRY = 60
@@ -197,3 +198,27 @@ def header(headers, name):
197
198
  """A header from a dict with lowercase names (or None)."""
198
199
  value = headers.get(name.lower())
199
200
  return value.strip() if isinstance(value, str) else ""
201
+
202
+
203
+ # The characters a path may carry unencoded (RFC 3986), which quote() leaves alone.
204
+ _PATH_SAFE = "/:@!$&'()*+,;=-._~"
205
+
206
+
207
+ def request_target(path, query="", raw=None):
208
+ """The request target as the visitor sent it, query string and all, which
209
+ is what the request rules match (sdk/protocols/firewall.md): the Node and
210
+ PHP clients hand over req.url and REQUEST_URI. raw: the server's own copy
211
+ of it when it keeps one. Otherwise the path the framework has already
212
+ decoded is encoded again, so the rules' one decoding pass is the only one.
213
+ path: text, or the bytes it was decoded from."""
214
+ if isinstance(raw, bytes):
215
+ raw = raw.decode("latin-1")
216
+ if isinstance(raw, str) and raw:
217
+ target = raw
218
+ else:
219
+ target = urllib.parse.quote(path or "", safe=_PATH_SAFE)
220
+ if isinstance(query, bytes):
221
+ query = query.decode("latin-1")
222
+ if query and "?" not in target:
223
+ target += "?" + query
224
+ return target
@@ -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
 
@@ -74,7 +75,9 @@ class PlatformMiddleware:
74
75
  for name, value in extra:
75
76
  response[name] = value
76
77
  return response
77
- blocked = self.platform.blocked(ip, headers, request.method, request.path)
78
+ # The target as sent: request.path is decoded, get_full_path() encodes it again.
79
+ target = request.META.get("RAW_URI") or request.META.get("REQUEST_URI") or request.get_full_path()
80
+ blocked = self.platform.blocked(ip, headers, request.method, target)
78
81
  if blocked:
79
82
  status, body, extra = blocked
80
83
  response = HttpResponse(body, status=status)
@@ -90,23 +93,34 @@ class PlatformMiddleware:
90
93
  for name, value in closed[1]:
91
94
  response[name] = value
92
95
  return response
93
- 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)
94
103
  for name, value in self.platform.security_headers(response.headers.keys()):
95
104
  response[name] = value
96
105
  if closed:
97
106
  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)
107
+ self.platform.time_request(request.method, _route(request), response.status_code, started, request.path)
102
108
  self.platform.record_crawler(headers.get("user-agent", ""), request.path, response.status_code, started,
103
109
  response.headers.get("Content-Length") or 0, ip)
104
110
  return response
105
111
 
106
112
  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
- })
113
+ self.platform.capture_exception(exception, status=500, kind="error", request=_request_fields(request))
112
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, "")}