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.
- {analytics_platform-0.2.1 → analytics_platform-0.3.0}/PKG-INFO +25 -3
- {analytics_platform-0.2.1 → analytics_platform-0.3.0}/README.md +24 -2
- {analytics_platform-0.2.1 → analytics_platform-0.3.0}/analytics_platform/__init__.py +93 -5
- {analytics_platform-0.2.1 → analytics_platform-0.3.0}/analytics_platform/core.py +26 -1
- {analytics_platform-0.2.1 → analytics_platform-0.3.0}/analytics_platform/django.py +25 -11
- analytics_platform-0.3.0/analytics_platform/errors.py +322 -0
- {analytics_platform-0.2.1 → analytics_platform-0.3.0}/analytics_platform/firewall.py +3 -2
- {analytics_platform-0.2.1 → analytics_platform-0.3.0}/analytics_platform/proxy.py +1 -0
- {analytics_platform-0.2.1 → analytics_platform-0.3.0}/analytics_platform/request_rules.py +96 -17
- {analytics_platform-0.2.1 → analytics_platform-0.3.0}/analytics_platform/resources.py +22 -3
- {analytics_platform-0.2.1 → analytics_platform-0.3.0}/analytics_platform.egg-info/PKG-INFO +25 -3
- {analytics_platform-0.2.1 → analytics_platform-0.3.0}/analytics_platform.egg-info/SOURCES.txt +2 -0
- {analytics_platform-0.2.1 → analytics_platform-0.3.0}/pyproject.toml +1 -1
- analytics_platform-0.3.0/tests/test_errors.py +253 -0
- analytics_platform-0.3.0/tests/test_firewall.py +56 -0
- {analytics_platform-0.2.1 → analytics_platform-0.3.0}/tests/test_request_rules.py +32 -0
- {analytics_platform-0.2.1 → analytics_platform-0.3.0}/tests/test_resources.py +35 -0
- analytics_platform-0.2.1/analytics_platform/errors.py +0 -113
- {analytics_platform-0.2.1 → analytics_platform-0.3.0}/LICENSE +0 -0
- {analytics_platform-0.2.1 → analytics_platform-0.3.0}/analytics_platform/analytics.py +0 -0
- {analytics_platform-0.2.1 → analytics_platform-0.3.0}/analytics_platform/audit.py +0 -0
- {analytics_platform-0.2.1 → analytics_platform-0.3.0}/analytics_platform/crawlers.py +0 -0
- {analytics_platform-0.2.1 → analytics_platform-0.3.0}/analytics_platform/flags.py +0 -0
- {analytics_platform-0.2.1 → analytics_platform-0.3.0}/analytics_platform/forms.py +0 -0
- {analytics_platform-0.2.1 → analytics_platform-0.3.0}/analytics_platform/inventory.py +0 -0
- {analytics_platform-0.2.1 → analytics_platform-0.3.0}/analytics_platform/maintenance.py +0 -0
- {analytics_platform-0.2.1 → analytics_platform-0.3.0}/analytics_platform/performance.py +0 -0
- {analytics_platform-0.2.1 → analytics_platform-0.3.0}/analytics_platform/posture.py +0 -0
- {analytics_platform-0.2.1 → analytics_platform-0.3.0}/analytics_platform.egg-info/dependency_links.txt +0 -0
- {analytics_platform-0.2.1 → analytics_platform-0.3.0}/analytics_platform.egg-info/top_level.txt +0 -0
- {analytics_platform-0.2.1 → analytics_platform-0.3.0}/setup.cfg +0 -0
- {analytics_platform-0.2.1 → analytics_platform-0.3.0}/tests/test_crawlers.py +0 -0
- {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.
|
|
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 →
|
|
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`, `
|
|
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 →
|
|
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`, `
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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, "")}
|