analytics-platform 0.2.2__tar.gz → 0.3.1__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.
- {analytics_platform-0.2.2 → analytics_platform-0.3.1}/PKG-INFO +34 -2
- {analytics_platform-0.2.2 → analytics_platform-0.3.1}/README.md +33 -1
- {analytics_platform-0.2.2 → analytics_platform-0.3.1}/analytics_platform/__init__.py +79 -1
- {analytics_platform-0.2.2 → analytics_platform-0.3.1}/analytics_platform/core.py +1 -1
- {analytics_platform-0.2.2 → analytics_platform-0.3.1}/analytics_platform/django.py +22 -10
- analytics_platform-0.3.1/analytics_platform/errors.py +322 -0
- {analytics_platform-0.2.2 → analytics_platform-0.3.1}/analytics_platform/proxy.py +10 -0
- {analytics_platform-0.2.2 → analytics_platform-0.3.1}/analytics_platform.egg-info/PKG-INFO +34 -2
- {analytics_platform-0.2.2 → analytics_platform-0.3.1}/analytics_platform.egg-info/SOURCES.txt +1 -0
- {analytics_platform-0.2.2 → analytics_platform-0.3.1}/pyproject.toml +1 -1
- analytics_platform-0.3.1/tests/test_errors.py +253 -0
- {analytics_platform-0.2.2 → analytics_platform-0.3.1}/tests/test_proxy.py +6 -1
- analytics_platform-0.2.2/analytics_platform/errors.py +0 -113
- {analytics_platform-0.2.2 → analytics_platform-0.3.1}/LICENSE +0 -0
- {analytics_platform-0.2.2 → analytics_platform-0.3.1}/analytics_platform/analytics.py +0 -0
- {analytics_platform-0.2.2 → analytics_platform-0.3.1}/analytics_platform/audit.py +0 -0
- {analytics_platform-0.2.2 → analytics_platform-0.3.1}/analytics_platform/crawlers.py +0 -0
- {analytics_platform-0.2.2 → analytics_platform-0.3.1}/analytics_platform/firewall.py +0 -0
- {analytics_platform-0.2.2 → analytics_platform-0.3.1}/analytics_platform/flags.py +0 -0
- {analytics_platform-0.2.2 → analytics_platform-0.3.1}/analytics_platform/forms.py +0 -0
- {analytics_platform-0.2.2 → analytics_platform-0.3.1}/analytics_platform/inventory.py +0 -0
- {analytics_platform-0.2.2 → analytics_platform-0.3.1}/analytics_platform/maintenance.py +0 -0
- {analytics_platform-0.2.2 → analytics_platform-0.3.1}/analytics_platform/performance.py +0 -0
- {analytics_platform-0.2.2 → analytics_platform-0.3.1}/analytics_platform/posture.py +0 -0
- {analytics_platform-0.2.2 → analytics_platform-0.3.1}/analytics_platform/request_rules.py +0 -0
- {analytics_platform-0.2.2 → analytics_platform-0.3.1}/analytics_platform/resources.py +0 -0
- {analytics_platform-0.2.2 → analytics_platform-0.3.1}/analytics_platform.egg-info/dependency_links.txt +0 -0
- {analytics_platform-0.2.2 → analytics_platform-0.3.1}/analytics_platform.egg-info/top_level.txt +0 -0
- {analytics_platform-0.2.2 → analytics_platform-0.3.1}/setup.cfg +0 -0
- {analytics_platform-0.2.2 → analytics_platform-0.3.1}/tests/test_crawlers.py +0 -0
- {analytics_platform-0.2.2 → analytics_platform-0.3.1}/tests/test_firewall.py +0 -0
- {analytics_platform-0.2.2 → analytics_platform-0.3.1}/tests/test_request_rules.py +0 -0
- {analytics_platform-0.2.2 → analytics_platform-0.3.1}/tests/test_resources.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: analytics-platform
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.1
|
|
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,40 @@ 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
|
+
|
|
182
|
+
**Several apps on one site** (microservices, a worker, a Node service next to
|
|
183
|
+
this one): install the package in each, with the same `site_id`, `secret` and
|
|
184
|
+
`endpoint`, and a `server_name` of its own. Errors, Server speed, Crawlers and
|
|
185
|
+
Security → Updates then tell the apps apart. Without one, each app is named
|
|
186
|
+
after its machine: two apps on one machine are reported as one, and only the
|
|
187
|
+
last package list sent is kept. A service only your other apps call takes
|
|
188
|
+
`firewall=False, proxy=False, maintenance=False` (its requests all come from
|
|
189
|
+
your own apps, so the rate limit would see one visitor); on a machine shared
|
|
190
|
+
by several apps, keep `resources` on in only one of them.
|
|
191
|
+
|
|
161
192
|
Options: `server_name`, `root` (traceback paths relative to it),
|
|
162
|
-
`capture_uncaught`, `
|
|
193
|
+
`capture_uncaught`, `capture_logging`, `spool_dir` (where unsent errors wait;
|
|
194
|
+
`False` for none), `before_send(event, error)` (change an error, or return
|
|
163
195
|
`None` to drop it).
|
|
164
196
|
|
|
165
197
|
## Server analytics
|
|
@@ -132,8 +132,40 @@ 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
|
+
|
|
156
|
+
**Several apps on one site** (microservices, a worker, a Node service next to
|
|
157
|
+
this one): install the package in each, with the same `site_id`, `secret` and
|
|
158
|
+
`endpoint`, and a `server_name` of its own. Errors, Server speed, Crawlers and
|
|
159
|
+
Security → Updates then tell the apps apart. Without one, each app is named
|
|
160
|
+
after its machine: two apps on one machine are reported as one, and only the
|
|
161
|
+
last package list sent is kept. A service only your other apps call takes
|
|
162
|
+
`firewall=False, proxy=False, maintenance=False` (its requests all come from
|
|
163
|
+
your own apps, so the rate limit would see one visitor); on a machine shared
|
|
164
|
+
by several apps, keep `resources` on in only one of them.
|
|
165
|
+
|
|
135
166
|
Options: `server_name`, `root` (traceback paths relative to it),
|
|
136
|
-
`capture_uncaught`, `
|
|
167
|
+
`capture_uncaught`, `capture_logging`, `spool_dir` (where unsent errors wait;
|
|
168
|
+
`False` for none), `before_send(event, error)` (change an error, or return
|
|
137
169
|
`None` to drop it).
|
|
138
170
|
|
|
139
171
|
## 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,14 @@ 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)
|
|
103
|
+
# Inventory and posture call it host, and are stored one per site
|
|
104
|
+
# and host: without it two services on one machine overwrite each other's.
|
|
105
|
+
if server_name and name in ("inventory", "posture"):
|
|
106
|
+
opts.setdefault("host", server_name)
|
|
97
107
|
try:
|
|
98
108
|
self._modules[name] = _load(name, self.core, opts)
|
|
99
109
|
except Exception as error:
|
|
@@ -144,6 +154,19 @@ class Platform:
|
|
|
144
154
|
if errors:
|
|
145
155
|
errors.capture_exception(error, request=request, status=status, kind=kind)
|
|
146
156
|
|
|
157
|
+
def job(self, name):
|
|
158
|
+
"""A background job (a queue consumer, a cron task), as a decorator or
|
|
159
|
+
a with block: its errors are sent with the job's name as their route,
|
|
160
|
+
and each run is timed (Health → Server speed → Jobs); a run that raises
|
|
161
|
+
counts as failed, and the exception is raised on.
|
|
162
|
+
|
|
163
|
+
@platform.job("send-receipts")
|
|
164
|
+
def send_receipts(): ...
|
|
165
|
+
|
|
166
|
+
with platform.job("nightly-import"): ...
|
|
167
|
+
"""
|
|
168
|
+
return _Job(self, name)
|
|
169
|
+
|
|
147
170
|
def login_failed(self, request_or_ip):
|
|
148
171
|
"""A failed login (bot protection): the address, or a Django, Flask or
|
|
149
172
|
Starlette request (its address as trust_proxy says)."""
|
|
@@ -417,6 +440,53 @@ class _Headers:
|
|
|
417
440
|
pass
|
|
418
441
|
|
|
419
442
|
|
|
443
|
+
class _Job:
|
|
444
|
+
"""platform.job(name): a with block (sync or async) or a decorator."""
|
|
445
|
+
|
|
446
|
+
def __init__(self, platform, name):
|
|
447
|
+
self.platform = platform
|
|
448
|
+
self.name = name
|
|
449
|
+
self.route = "/(job) " + str(name or "job")[:180]
|
|
450
|
+
self._runs = []
|
|
451
|
+
|
|
452
|
+
def _fields(self):
|
|
453
|
+
return {"method": "JOB", "path": self.route, "route": self.route, "session_id": ""}
|
|
454
|
+
|
|
455
|
+
def __enter__(self):
|
|
456
|
+
self._runs.append((time.perf_counter(), current_request.set(self._fields)))
|
|
457
|
+
return self
|
|
458
|
+
|
|
459
|
+
def __exit__(self, kind, value, tb):
|
|
460
|
+
started, token = self._runs.pop()
|
|
461
|
+
current_request.reset(token)
|
|
462
|
+
if value is not None and not isinstance(value, Exception):
|
|
463
|
+
return False # cancelled or interrupted (CancelledError, KeyboardInterrupt): not a run that failed
|
|
464
|
+
if value is not None:
|
|
465
|
+
self.platform.capture_exception(value, status=500, kind="error", request=self._fields())
|
|
466
|
+
self.platform.time_request("JOB", self.route, 500 if value is not None else 200, started, self.route)
|
|
467
|
+
return False
|
|
468
|
+
|
|
469
|
+
async def __aenter__(self):
|
|
470
|
+
return self.__enter__()
|
|
471
|
+
|
|
472
|
+
async def __aexit__(self, kind, value, tb):
|
|
473
|
+
return self.__exit__(kind, value, tb)
|
|
474
|
+
|
|
475
|
+
def __call__(self, fn):
|
|
476
|
+
if asyncio.iscoroutinefunction(fn):
|
|
477
|
+
@functools.wraps(fn)
|
|
478
|
+
async def run_async(*args, **kwargs):
|
|
479
|
+
with _Job(self.platform, self.name):
|
|
480
|
+
return await fn(*args, **kwargs)
|
|
481
|
+
return run_async
|
|
482
|
+
|
|
483
|
+
@functools.wraps(fn)
|
|
484
|
+
def run(*args, **kwargs):
|
|
485
|
+
with _Job(self.platform, self.name):
|
|
486
|
+
return fn(*args, **kwargs)
|
|
487
|
+
return run
|
|
488
|
+
|
|
489
|
+
|
|
420
490
|
class PlatformMiddleware:
|
|
421
491
|
"""ASGI middleware: app.add_middleware(PlatformMiddleware, platform=platform)."""
|
|
422
492
|
|
|
@@ -479,6 +549,13 @@ class PlatformMiddleware:
|
|
|
479
549
|
message = dict(message, headers=list(message.get("headers") or []) + [(n.lower().encode(), v.encode("latin-1", "replace")) for n, v in extra] + remember)
|
|
480
550
|
await send(message)
|
|
481
551
|
|
|
552
|
+
# Errors captured during the request (logged, or caught without the
|
|
553
|
+
# request) still know which request they came from. The router sets
|
|
554
|
+
# scope["route"] on the way in, so it is read when the error is.
|
|
555
|
+
token = current_request.set(lambda: {
|
|
556
|
+
"method": scope.get("method", ""), "path": scope.get("path", ""),
|
|
557
|
+
"route": getattr(scope.get("route"), "path", ""), "session_id": headers.get(SESSION_HEADER, ""),
|
|
558
|
+
})
|
|
482
559
|
try:
|
|
483
560
|
await self.app(scope, receive, timed_send)
|
|
484
561
|
except Exception as error:
|
|
@@ -488,6 +565,7 @@ class PlatformMiddleware:
|
|
|
488
565
|
})
|
|
489
566
|
raise
|
|
490
567
|
finally:
|
|
568
|
+
current_request.reset(token)
|
|
491
569
|
# The router sets scope["route"] (FastAPI, Starlette) on the way in.
|
|
492
570
|
self.platform.time_request(scope.get("method", ""), getattr(scope.get("route"), "path", "") or "",
|
|
493
571
|
seen["status"], started, scope.get("path", ""))
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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()
|
|
@@ -47,6 +47,13 @@ def prefix_for(secret):
|
|
|
47
47
|
return hashlib.sha256(("proxy|" + str(secret)).encode("utf-8")).hexdigest()[:8]
|
|
48
48
|
|
|
49
49
|
|
|
50
|
+
# Sent as X-Proxy-Key, so the collector believes the visitor addresses this
|
|
51
|
+
# proxy passes on: anyone can write X-Forwarded-For. Worked out from the secret
|
|
52
|
+
# as the collector does (expresscollect/utils/proxyKey.js).
|
|
53
|
+
def key_for(secret):
|
|
54
|
+
return hashlib.sha256(("proxykey|" + str(secret)).encode("utf-8")).hexdigest()[:32]
|
|
55
|
+
|
|
56
|
+
|
|
50
57
|
class Proxy:
|
|
51
58
|
"""prefix: the path it answers on (default: from the secret)."""
|
|
52
59
|
|
|
@@ -54,6 +61,7 @@ class Proxy:
|
|
|
54
61
|
self.core = core
|
|
55
62
|
self.enabled = enabled
|
|
56
63
|
self.prefix = re.sub(r"[^A-Za-z0-9_-]", "", str(prefix or prefix_for(core.secret)))
|
|
64
|
+
self.key = key_for(core.secret) if core.secret else ""
|
|
57
65
|
# One copy of each tracker file, so a repeat visitor costs no upstream
|
|
58
66
|
# request. A handful of small files: it never grows.
|
|
59
67
|
self._files = {}
|
|
@@ -139,6 +147,8 @@ class Proxy:
|
|
|
139
147
|
# The visitor's address: the collector needs it for the country, the
|
|
140
148
|
# daily visitor ID and the per-visitor rate limit.
|
|
141
149
|
out = {"X-Forwarded-For": client_ip or ""}
|
|
150
|
+
if self.key:
|
|
151
|
+
out["X-Proxy-Key"] = self.key
|
|
142
152
|
for name in PASS_HEADERS:
|
|
143
153
|
value = headers.get(name)
|
|
144
154
|
if value:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: analytics-platform
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.1
|
|
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,40 @@ 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
|
+
|
|
182
|
+
**Several apps on one site** (microservices, a worker, a Node service next to
|
|
183
|
+
this one): install the package in each, with the same `site_id`, `secret` and
|
|
184
|
+
`endpoint`, and a `server_name` of its own. Errors, Server speed, Crawlers and
|
|
185
|
+
Security → Updates then tell the apps apart. Without one, each app is named
|
|
186
|
+
after its machine: two apps on one machine are reported as one, and only the
|
|
187
|
+
last package list sent is kept. A service only your other apps call takes
|
|
188
|
+
`firewall=False, proxy=False, maintenance=False` (its requests all come from
|
|
189
|
+
your own apps, so the rate limit would see one visitor); on a machine shared
|
|
190
|
+
by several apps, keep `resources` on in only one of them.
|
|
191
|
+
|
|
161
192
|
Options: `server_name`, `root` (traceback paths relative to it),
|
|
162
|
-
`capture_uncaught`, `
|
|
193
|
+
`capture_uncaught`, `capture_logging`, `spool_dir` (where unsent errors wait;
|
|
194
|
+
`False` for none), `before_send(event, error)` (change an error, or return
|
|
163
195
|
`None` to drop it).
|
|
164
196
|
|
|
165
197
|
## Server analytics
|
{analytics_platform-0.2.2 → analytics_platform-0.3.1}/analytics_platform.egg-info/SOURCES.txt
RENAMED
|
@@ -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.
|
|
7
|
+
version = "0.3.1"
|
|
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()
|
|
@@ -12,7 +12,7 @@ from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
|
12
12
|
|
|
13
13
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
|
14
14
|
|
|
15
|
-
from analytics_platform.proxy import Proxy, prefix_for # noqa: E402
|
|
15
|
+
from analytics_platform.proxy import Proxy, key_for, prefix_for # noqa: E402
|
|
16
16
|
|
|
17
17
|
TRACKER = b"// a tracker" + b"x" * 600
|
|
18
18
|
|
|
@@ -77,6 +77,10 @@ class ProxyTest(unittest.TestCase):
|
|
|
77
77
|
Collector.seen = []
|
|
78
78
|
self.proxy = Proxy(FakeCore(self.endpoint))
|
|
79
79
|
|
|
80
|
+
def test_the_proxy_key_is_worked_out_as_the_collector_does(self):
|
|
81
|
+
# The same vector is pinned in the collector, the backend and the other packages.
|
|
82
|
+
self.assertEqual(key_for("0123456789abcdef01234567"), "8fc74dbbd4f088449862841cd29fcaee")
|
|
83
|
+
|
|
80
84
|
def test_the_address_comes_from_the_secret(self):
|
|
81
85
|
self.assertEqual(prefix_for("s3cret"), prefix_for("s3cret"))
|
|
82
86
|
self.assertNotEqual(prefix_for("s3cret"), prefix_for("other"))
|
|
@@ -123,6 +127,7 @@ class ProxyTest(unittest.TestCase):
|
|
|
123
127
|
self.assertEqual(got["path"], "/collectanalytics/e?v=1")
|
|
124
128
|
self.assertEqual(got["body"], b'{"event":"pageview"}')
|
|
125
129
|
self.assertEqual(got["headers"].get("X-Forwarded-For"), "198.51.100.4")
|
|
130
|
+
self.assertEqual(got["headers"].get("X-Proxy-Key"), key_for("s3cret"), "so the collector believes the address")
|
|
126
131
|
self.assertEqual(got["headers"].get("User-Agent"), "Mozilla/5.0")
|
|
127
132
|
self.assertIsNone(got["headers"].get("Cookie"), "the site's own cookies stay on the site")
|
|
128
133
|
|
|
@@ -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()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{analytics_platform-0.2.2 → analytics_platform-0.3.1}/analytics_platform.egg-info/top_level.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|