analytics-platform 0.3.6__tar.gz → 0.3.8__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.3.6 → analytics_platform-0.3.8}/PKG-INFO +58 -21
- {analytics_platform-0.3.6 → analytics_platform-0.3.8}/README.md +57 -20
- {analytics_platform-0.3.6 → analytics_platform-0.3.8}/analytics_platform/__init__.py +19 -5
- analytics_platform-0.3.8/analytics_platform/_env.py +71 -0
- {analytics_platform-0.3.6 → analytics_platform-0.3.8}/analytics_platform/analytics.py +23 -4
- {analytics_platform-0.3.6 → analytics_platform-0.3.8}/analytics_platform/core.py +1 -1
- {analytics_platform-0.3.6 → analytics_platform-0.3.8}/analytics_platform/django.py +14 -6
- analytics_platform-0.3.8/analytics_platform/start.py +118 -0
- {analytics_platform-0.3.6 → analytics_platform-0.3.8}/analytics_platform.egg-info/PKG-INFO +58 -21
- {analytics_platform-0.3.6 → analytics_platform-0.3.8}/analytics_platform.egg-info/SOURCES.txt +3 -0
- {analytics_platform-0.3.6 → analytics_platform-0.3.8}/pyproject.toml +1 -1
- {analytics_platform-0.3.6 → analytics_platform-0.3.8}/tests/test_hardening.py +15 -0
- analytics_platform-0.3.8/tests/test_start.py +156 -0
- {analytics_platform-0.3.6 → analytics_platform-0.3.8}/LICENSE +0 -0
- {analytics_platform-0.3.6 → analytics_platform-0.3.8}/analytics_platform/audit.py +0 -0
- {analytics_platform-0.3.6 → analytics_platform-0.3.8}/analytics_platform/calls.py +0 -0
- {analytics_platform-0.3.6 → analytics_platform-0.3.8}/analytics_platform/celery.py +0 -0
- {analytics_platform-0.3.6 → analytics_platform-0.3.8}/analytics_platform/challenge.py +0 -0
- {analytics_platform-0.3.6 → analytics_platform-0.3.8}/analytics_platform/crawlers.py +0 -0
- {analytics_platform-0.3.6 → analytics_platform-0.3.8}/analytics_platform/dramatiq.py +0 -0
- {analytics_platform-0.3.6 → analytics_platform-0.3.8}/analytics_platform/errors.py +0 -0
- {analytics_platform-0.3.6 → analytics_platform-0.3.8}/analytics_platform/firewall.py +0 -0
- {analytics_platform-0.3.6 → analytics_platform-0.3.8}/analytics_platform/flags.py +0 -0
- {analytics_platform-0.3.6 → analytics_platform-0.3.8}/analytics_platform/forms.py +0 -0
- {analytics_platform-0.3.6 → analytics_platform-0.3.8}/analytics_platform/inventory.py +0 -0
- {analytics_platform-0.3.6 → analytics_platform-0.3.8}/analytics_platform/maintenance.py +0 -0
- {analytics_platform-0.3.6 → analytics_platform-0.3.8}/analytics_platform/performance.py +0 -0
- {analytics_platform-0.3.6 → analytics_platform-0.3.8}/analytics_platform/posture.py +0 -0
- {analytics_platform-0.3.6 → analytics_platform-0.3.8}/analytics_platform/proxy.py +0 -0
- {analytics_platform-0.3.6 → analytics_platform-0.3.8}/analytics_platform/queries.py +0 -0
- {analytics_platform-0.3.6 → analytics_platform-0.3.8}/analytics_platform/queues.py +0 -0
- {analytics_platform-0.3.6 → analytics_platform-0.3.8}/analytics_platform/request_rules.py +0 -0
- {analytics_platform-0.3.6 → analytics_platform-0.3.8}/analytics_platform/resources.py +0 -0
- {analytics_platform-0.3.6 → analytics_platform-0.3.8}/analytics_platform/rq.py +0 -0
- {analytics_platform-0.3.6 → analytics_platform-0.3.8}/analytics_platform.egg-info/dependency_links.txt +0 -0
- {analytics_platform-0.3.6 → analytics_platform-0.3.8}/analytics_platform.egg-info/top_level.txt +0 -0
- {analytics_platform-0.3.6 → analytics_platform-0.3.8}/setup.cfg +0 -0
- {analytics_platform-0.3.6 → analytics_platform-0.3.8}/tests/test_calls_queues.py +0 -0
- {analytics_platform-0.3.6 → analytics_platform-0.3.8}/tests/test_celery.py +0 -0
- {analytics_platform-0.3.6 → analytics_platform-0.3.8}/tests/test_challenge.py +0 -0
- {analytics_platform-0.3.6 → analytics_platform-0.3.8}/tests/test_checkin.py +0 -0
- {analytics_platform-0.3.6 → analytics_platform-0.3.8}/tests/test_crawlers.py +0 -0
- {analytics_platform-0.3.6 → analytics_platform-0.3.8}/tests/test_errors.py +0 -0
- {analytics_platform-0.3.6 → analytics_platform-0.3.8}/tests/test_firewall.py +0 -0
- {analytics_platform-0.3.6 → analytics_platform-0.3.8}/tests/test_limits.py +0 -0
- {analytics_platform-0.3.6 → analytics_platform-0.3.8}/tests/test_page_limits.py +0 -0
- {analytics_platform-0.3.6 → analytics_platform-0.3.8}/tests/test_proxy.py +0 -0
- {analytics_platform-0.3.6 → analytics_platform-0.3.8}/tests/test_queries.py +0 -0
- {analytics_platform-0.3.6 → analytics_platform-0.3.8}/tests/test_redis.py +0 -0
- {analytics_platform-0.3.6 → analytics_platform-0.3.8}/tests/test_request_rules.py +0 -0
- {analytics_platform-0.3.6 → analytics_platform-0.3.8}/tests/test_resources.py +0 -0
- {analytics_platform-0.3.6 → analytics_platform-0.3.8}/tests/test_rq_dramatiq.py +0 -0
- {analytics_platform-0.3.6 → analytics_platform-0.3.8}/tests/test_static_late.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: analytics-platform
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.8
|
|
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
|
|
@@ -35,7 +35,42 @@ dependencies; Python 3.8 or later.
|
|
|
35
35
|
pip install analytics-platform
|
|
36
36
|
```
|
|
37
37
|
|
|
38
|
-
|
|
38
|
+
Put your keys in `.env` (site settings has all three, ready to copy), or in
|
|
39
|
+
your host's environment:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
ANALYTICS_SITE_ID=YOUR_SITE_ID
|
|
43
|
+
ANALYTICS_SITE_SECRET=YOUR_SITE_SECRET
|
|
44
|
+
ANALYTICS_ENDPOINT=https://YOUR_COLLECTOR_URL
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
The package reads only the `ANALYTICS_*` lines of the `.env` in the folder the
|
|
48
|
+
server starts in (Django: beside `manage.py`), and never writes them into
|
|
49
|
+
`os.environ`, so your own `.env` loading is unaffected. Behind Cloudflare, add
|
|
50
|
+
`ANALYTICS_TRUST_PROXY=cloudflare`; behind Nginx or a load balancer,
|
|
51
|
+
`ANALYTICS_TRUST_PROXY=1`. Several services on one site? Give each
|
|
52
|
+
`ANALYTICS_SERVER_NAME=api` (or `worker`, and so on).
|
|
53
|
+
|
|
54
|
+
## Flask and FastAPI: one line
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
from analytics_platform.start import platform
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Put it near the top of the module that makes your app. On the app's first
|
|
61
|
+
request, the package puts itself in front of it: Flask through
|
|
62
|
+
`init_flask(app)`, and FastAPI or any Starlette app through its middleware, as
|
|
63
|
+
the outermost of yours. It works whether the import is above or below
|
|
64
|
+
`app = Flask(...)`. An app that already has the platform by hand gets nothing
|
|
65
|
+
added a second time, and a FastAPI app mounted inside another isn't counted
|
|
66
|
+
twice.
|
|
67
|
+
|
|
68
|
+
Use the same import wherever you call `platform.track()` and the rest: it's
|
|
69
|
+
one platform for the whole process. It's made on first use, not on import, so
|
|
70
|
+
under Gunicorn or uWSGI with `--preload` its background threads start in each
|
|
71
|
+
worker, not in the master.
|
|
72
|
+
|
|
73
|
+
## Django: one line in settings
|
|
39
74
|
|
|
40
75
|
```python
|
|
41
76
|
# settings.py
|
|
@@ -43,36 +78,31 @@ MIDDLEWARE = [
|
|
|
43
78
|
"analytics_platform.django.PlatformMiddleware", # first
|
|
44
79
|
# ...
|
|
45
80
|
]
|
|
81
|
+
```
|
|
46
82
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
# "trust_proxy": "cloudflare", # every request through Cloudflare
|
|
53
|
-
}
|
|
83
|
+
That's all with the keys in `.env` or the environment. Options in code go in
|
|
84
|
+
`ANALYTICS_PLATFORM`, and win over the environment:
|
|
85
|
+
|
|
86
|
+
```python
|
|
87
|
+
ANALYTICS_PLATFORM = {"trust_proxy": 1, "errors": {"capture_logging": True}}
|
|
54
88
|
```
|
|
55
89
|
|
|
56
90
|
In your code: `from analytics_platform.django import get_platform`. The
|
|
57
91
|
package tells the collector it is a Django site (`analytics-platform-django/…`),
|
|
58
92
|
so attack patterns written for Django reach it.
|
|
59
93
|
|
|
60
|
-
##
|
|
94
|
+
## Setting it up in code instead
|
|
95
|
+
|
|
96
|
+
For options in code with Flask, FastAPI or another app (use this or the line
|
|
97
|
+
above, not both). Any key left out is read from `ANALYTICS_*`:
|
|
61
98
|
|
|
62
99
|
```python
|
|
63
100
|
from analytics_platform import create_platform, PlatformMiddleware
|
|
64
101
|
|
|
65
|
-
platform = create_platform(
|
|
66
|
-
endpoint="https://YOUR_COLLECTOR_URL")
|
|
67
|
-
app.add_middleware(PlatformMiddleware, platform=platform)
|
|
68
|
-
```
|
|
69
|
-
|
|
70
|
-
## Flask
|
|
102
|
+
platform = create_platform(errors={"capture_logging": True})
|
|
71
103
|
|
|
72
|
-
|
|
73
|
-
platform
|
|
74
|
-
endpoint="https://YOUR_COLLECTOR_URL")
|
|
75
|
-
platform.init_flask(app)
|
|
104
|
+
app.add_middleware(PlatformMiddleware, platform=platform) # FastAPI / Starlette
|
|
105
|
+
platform.init_flask(app) # Flask
|
|
76
106
|
```
|
|
77
107
|
|
|
78
108
|
Other WSGI apps: `app = platform.wsgi(app)`; other ASGI apps: `app = platform.asgi(app)`.
|
|
@@ -423,7 +453,7 @@ For what only your server knows for sure. Not pageviews: the tracker counts thos
|
|
|
423
453
|
session = platform.session_id(request) # the visit, from the X-Analytics-Session header
|
|
424
454
|
|
|
425
455
|
platform.track("purchase", {"numeric_value": "49.90", "currency": "EUR", "event_uid": f"order-{order.id}"}, session_id=session)
|
|
426
|
-
platform.identify(user.id, session_id=session)
|
|
456
|
+
platform.identify(user.id, session_id=session, traits={"plan": "pro", "seats": 5, "trial": False}) # traits are optional
|
|
427
457
|
platform.lead({"email": "ana@example.com", "name": "Ana", "status": "qualified"})
|
|
428
458
|
```
|
|
429
459
|
|
|
@@ -433,6 +463,13 @@ answer (up to 5 seconds). What you pass is taken as JSON when you pass it
|
|
|
433
463
|
a set a list, `NaN` null and anything else its `str()`, so one odd value can't
|
|
434
464
|
fail the batch it is sent in.
|
|
435
465
|
|
|
466
|
+
**User traits:** `identify()` takes optional `traits`, a dict of name to
|
|
467
|
+
text, number or `True`/`False`. Lists, dicts and other values are left out
|
|
468
|
+
(and `traits` is left off when nothing is left). The collector does the rest:
|
|
469
|
+
names are lowercased with spaces and dashes as `_` (up to 40 letters, digits
|
|
470
|
+
and `_`), text is cut at 200 characters, values that look like an email
|
|
471
|
+
address are dropped, and at most 20 are kept.
|
|
472
|
+
|
|
436
473
|
**Link to the visit:** send the tracker's session ID with your site's requests:
|
|
437
474
|
|
|
438
475
|
```js
|
|
@@ -9,7 +9,42 @@ dependencies; Python 3.8 or later.
|
|
|
9
9
|
pip install analytics-platform
|
|
10
10
|
```
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
Put your keys in `.env` (site settings has all three, ready to copy), or in
|
|
13
|
+
your host's environment:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
ANALYTICS_SITE_ID=YOUR_SITE_ID
|
|
17
|
+
ANALYTICS_SITE_SECRET=YOUR_SITE_SECRET
|
|
18
|
+
ANALYTICS_ENDPOINT=https://YOUR_COLLECTOR_URL
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
The package reads only the `ANALYTICS_*` lines of the `.env` in the folder the
|
|
22
|
+
server starts in (Django: beside `manage.py`), and never writes them into
|
|
23
|
+
`os.environ`, so your own `.env` loading is unaffected. Behind Cloudflare, add
|
|
24
|
+
`ANALYTICS_TRUST_PROXY=cloudflare`; behind Nginx or a load balancer,
|
|
25
|
+
`ANALYTICS_TRUST_PROXY=1`. Several services on one site? Give each
|
|
26
|
+
`ANALYTICS_SERVER_NAME=api` (or `worker`, and so on).
|
|
27
|
+
|
|
28
|
+
## Flask and FastAPI: one line
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
from analytics_platform.start import platform
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Put it near the top of the module that makes your app. On the app's first
|
|
35
|
+
request, the package puts itself in front of it: Flask through
|
|
36
|
+
`init_flask(app)`, and FastAPI or any Starlette app through its middleware, as
|
|
37
|
+
the outermost of yours. It works whether the import is above or below
|
|
38
|
+
`app = Flask(...)`. An app that already has the platform by hand gets nothing
|
|
39
|
+
added a second time, and a FastAPI app mounted inside another isn't counted
|
|
40
|
+
twice.
|
|
41
|
+
|
|
42
|
+
Use the same import wherever you call `platform.track()` and the rest: it's
|
|
43
|
+
one platform for the whole process. It's made on first use, not on import, so
|
|
44
|
+
under Gunicorn or uWSGI with `--preload` its background threads start in each
|
|
45
|
+
worker, not in the master.
|
|
46
|
+
|
|
47
|
+
## Django: one line in settings
|
|
13
48
|
|
|
14
49
|
```python
|
|
15
50
|
# settings.py
|
|
@@ -17,36 +52,31 @@ MIDDLEWARE = [
|
|
|
17
52
|
"analytics_platform.django.PlatformMiddleware", # first
|
|
18
53
|
# ...
|
|
19
54
|
]
|
|
55
|
+
```
|
|
20
56
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
# "trust_proxy": "cloudflare", # every request through Cloudflare
|
|
27
|
-
}
|
|
57
|
+
That's all with the keys in `.env` or the environment. Options in code go in
|
|
58
|
+
`ANALYTICS_PLATFORM`, and win over the environment:
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
ANALYTICS_PLATFORM = {"trust_proxy": 1, "errors": {"capture_logging": True}}
|
|
28
62
|
```
|
|
29
63
|
|
|
30
64
|
In your code: `from analytics_platform.django import get_platform`. The
|
|
31
65
|
package tells the collector it is a Django site (`analytics-platform-django/…`),
|
|
32
66
|
so attack patterns written for Django reach it.
|
|
33
67
|
|
|
34
|
-
##
|
|
68
|
+
## Setting it up in code instead
|
|
69
|
+
|
|
70
|
+
For options in code with Flask, FastAPI or another app (use this or the line
|
|
71
|
+
above, not both). Any key left out is read from `ANALYTICS_*`:
|
|
35
72
|
|
|
36
73
|
```python
|
|
37
74
|
from analytics_platform import create_platform, PlatformMiddleware
|
|
38
75
|
|
|
39
|
-
platform = create_platform(
|
|
40
|
-
endpoint="https://YOUR_COLLECTOR_URL")
|
|
41
|
-
app.add_middleware(PlatformMiddleware, platform=platform)
|
|
42
|
-
```
|
|
43
|
-
|
|
44
|
-
## Flask
|
|
76
|
+
platform = create_platform(errors={"capture_logging": True})
|
|
45
77
|
|
|
46
|
-
|
|
47
|
-
platform
|
|
48
|
-
endpoint="https://YOUR_COLLECTOR_URL")
|
|
49
|
-
platform.init_flask(app)
|
|
78
|
+
app.add_middleware(PlatformMiddleware, platform=platform) # FastAPI / Starlette
|
|
79
|
+
platform.init_flask(app) # Flask
|
|
50
80
|
```
|
|
51
81
|
|
|
52
82
|
Other WSGI apps: `app = platform.wsgi(app)`; other ASGI apps: `app = platform.asgi(app)`.
|
|
@@ -397,7 +427,7 @@ For what only your server knows for sure. Not pageviews: the tracker counts thos
|
|
|
397
427
|
session = platform.session_id(request) # the visit, from the X-Analytics-Session header
|
|
398
428
|
|
|
399
429
|
platform.track("purchase", {"numeric_value": "49.90", "currency": "EUR", "event_uid": f"order-{order.id}"}, session_id=session)
|
|
400
|
-
platform.identify(user.id, session_id=session)
|
|
430
|
+
platform.identify(user.id, session_id=session, traits={"plan": "pro", "seats": 5, "trial": False}) # traits are optional
|
|
401
431
|
platform.lead({"email": "ana@example.com", "name": "Ana", "status": "qualified"})
|
|
402
432
|
```
|
|
403
433
|
|
|
@@ -407,6 +437,13 @@ answer (up to 5 seconds). What you pass is taken as JSON when you pass it
|
|
|
407
437
|
a set a list, `NaN` null and anything else its `str()`, so one odd value can't
|
|
408
438
|
fail the batch it is sent in.
|
|
409
439
|
|
|
440
|
+
**User traits:** `identify()` takes optional `traits`, a dict of name to
|
|
441
|
+
text, number or `True`/`False`. Lists, dicts and other values are left out
|
|
442
|
+
(and `traits` is left off when nothing is left). The collector does the rest:
|
|
443
|
+
names are lowercased with spaces and dashes as `_` (up to 40 letters, digits
|
|
444
|
+
and `_`), text is cut at 200 characters, values that look like an email
|
|
445
|
+
address are dropped, and at most 20 are kept.
|
|
446
|
+
|
|
410
447
|
**Link to the visit:** send the tracker's session ID with your site's requests:
|
|
411
448
|
|
|
412
449
|
```js
|
|
@@ -20,6 +20,7 @@ import http
|
|
|
20
20
|
import time
|
|
21
21
|
import weakref
|
|
22
22
|
|
|
23
|
+
from . import _env
|
|
23
24
|
from .core import Core, EXIT_DEADLINE, SESSION_HEADER, VERSION, begin_exit, header, request_target, within
|
|
24
25
|
from .errors import add_breadcrumb, current_request, current_trail
|
|
25
26
|
from .queries import current_queries
|
|
@@ -77,7 +78,8 @@ MODULES = ("proxy", "firewall", "errors", "analytics", "performance", "crawlers"
|
|
|
77
78
|
|
|
78
79
|
class Platform:
|
|
79
80
|
"""site_id, secret (site settings; keep it on the server), endpoint (the
|
|
80
|
-
collector's URL)
|
|
81
|
+
collector's URL), each read from ANALYTICS_SITE_ID, ANALYTICS_SITE_SECRET and
|
|
82
|
+
ANALYTICS_ENDPOINT when left out; trust_proxy (ANALYTICS_TRUST_PROXY) (how to find the visitor's address: None
|
|
81
83
|
uses the connection's, a number of proxies in X-Forwarded-For,
|
|
82
84
|
"cloudflare"); debug (print send problems); per module True / False / {options}.
|
|
83
85
|
client: the name this package goes by to the collector ("python", or
|
|
@@ -86,14 +88,20 @@ class Platform:
|
|
|
86
88
|
keep Server speed and leave database queries, outgoing calls or job
|
|
87
89
|
queues out of it."""
|
|
88
90
|
|
|
89
|
-
def __init__(self, site_id, secret, endpoint, trust_proxy=None, debug=False, firewall=True, errors=True, analytics=True,
|
|
91
|
+
def __init__(self, site_id=None, secret=None, endpoint=None, trust_proxy=None, debug=False, firewall=True, errors=True, analytics=True,
|
|
90
92
|
performance=True, crawlers=True, forms=True, headers=True, audit=True, flags=True, maintenance=True,
|
|
91
93
|
inventory=True,
|
|
92
94
|
posture=True, resources=True, proxy=True, server_name=None, client="python", queries=True,
|
|
93
95
|
calls=True, queues=True):
|
|
96
|
+
# Each of these left out is read from ANALYTICS_* in the environment (_env.py).
|
|
97
|
+
given = _env.from_environment({"site_id": site_id, "secret": secret, "endpoint": endpoint,
|
|
98
|
+
"trust_proxy": trust_proxy, "server_name": server_name})
|
|
99
|
+
site_id, secret, endpoint = given["site_id"], given["secret"], given["endpoint"]
|
|
100
|
+
trust_proxy, server_name = given["trust_proxy"], given["server_name"]
|
|
94
101
|
self.core = Core(site_id, secret, endpoint, debug, server_name=server_name, client=client)
|
|
95
102
|
if not self.core.valid:
|
|
96
|
-
print("[analytics-platform] site_id, secret and endpoint are required
|
|
103
|
+
print("[analytics-platform] site_id, secret and endpoint are required (or ANALYTICS_SITE_ID, ANALYTICS_SITE_SECRET "
|
|
104
|
+
"and ANALYTICS_ENDPOINT in the environment); nothing is sent and every request passes")
|
|
97
105
|
settings = {"firewall": firewall, "errors": errors, "analytics": analytics, "performance": performance,
|
|
98
106
|
"crawlers": crawlers, "forms": forms,
|
|
99
107
|
"headers": headers, "audit": audit, "flags": flags, "maintenance": maintenance, "inventory": inventory,
|
|
@@ -308,9 +316,9 @@ class Platform:
|
|
|
308
316
|
analytics = self._modules.get("analytics")
|
|
309
317
|
return analytics.track(name, fields, session_id) if analytics else ""
|
|
310
318
|
|
|
311
|
-
def identify(self, user_id, session_id=""):
|
|
319
|
+
def identify(self, user_id, session_id="", traits=None):
|
|
312
320
|
analytics = self._modules.get("analytics")
|
|
313
|
-
return analytics.identify(user_id, session_id) if analytics else False
|
|
321
|
+
return analytics.identify(user_id, session_id, traits) if analytics else False
|
|
314
322
|
|
|
315
323
|
def lead(self, fields, session_id=""):
|
|
316
324
|
analytics = self._modules.get("analytics")
|
|
@@ -577,6 +585,12 @@ class Platform:
|
|
|
577
585
|
|
|
578
586
|
def init_flask(self, app):
|
|
579
587
|
"""Flask: the firewall in front of the app, and its request errors."""
|
|
588
|
+
# Once per app, by hand or by analytics_platform.start.
|
|
589
|
+
extensions = getattr(app, "extensions", None)
|
|
590
|
+
if isinstance(extensions, dict):
|
|
591
|
+
if "analytics_platform" in extensions:
|
|
592
|
+
return app
|
|
593
|
+
extensions["analytics_platform"] = self
|
|
580
594
|
app.wsgi_app = self.wsgi(app.wsgi_app, timing=False)
|
|
581
595
|
from flask import g, got_request_exception, request
|
|
582
596
|
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""The options read from ANALYTICS_* when they aren't passed, as the Node and
|
|
2
|
+
Go packages do: ANALYTICS_SITE_ID, ANALYTICS_SITE_SECRET, ANALYTICS_ENDPOINT,
|
|
3
|
+
and optionally ANALYTICS_TRUST_PROXY ("cloudflare" or a number of proxies)
|
|
4
|
+
and ANALYTICS_SERVER_NAME. And the ANALYTICS_* lines of a .env file, for
|
|
5
|
+
analytics_platform.start and Django: read, never written to os.environ, so
|
|
6
|
+
whatever loads the app's own .env files still sets them as it would."""
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
|
|
10
|
+
_LINE = re.compile(r"^\s*(?:export\s+)?(ANALYTICS_[A-Z0-9_]+)\s*=\s*(.*)$")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def options_from(env):
|
|
14
|
+
"""{site_id, secret, endpoint, trust_proxy, server_name} found in env."""
|
|
15
|
+
def read(name):
|
|
16
|
+
value = env.get(name)
|
|
17
|
+
return value.strip() if isinstance(value, str) and value.strip() else None
|
|
18
|
+
|
|
19
|
+
found = {}
|
|
20
|
+
for option, name in (("site_id", "ANALYTICS_SITE_ID"), ("secret", "ANALYTICS_SITE_SECRET"),
|
|
21
|
+
("endpoint", "ANALYTICS_ENDPOINT"), ("server_name", "ANALYTICS_SERVER_NAME")):
|
|
22
|
+
if read(name):
|
|
23
|
+
found[option] = read(name)
|
|
24
|
+
if "endpoint" not in found and read("ANALYTICS_FIREWALL_ENDPOINT"):
|
|
25
|
+
found["endpoint"] = read("ANALYTICS_FIREWALL_ENDPOINT")
|
|
26
|
+
trust = read("ANALYTICS_TRUST_PROXY")
|
|
27
|
+
if trust:
|
|
28
|
+
if trust.lower() == "cloudflare":
|
|
29
|
+
found["trust_proxy"] = "cloudflare"
|
|
30
|
+
elif trust.lower() == "true":
|
|
31
|
+
found["trust_proxy"] = 1
|
|
32
|
+
elif trust.isdigit() and int(trust) > 0:
|
|
33
|
+
found["trust_proxy"] = int(trust)
|
|
34
|
+
return found
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def read_dotenv(path):
|
|
38
|
+
"""The ANALYTICS_* lines of a .env file as {name: value}; {} without one.
|
|
39
|
+
Not a dotenv: no expansion, and every other line is ignored."""
|
|
40
|
+
try:
|
|
41
|
+
with open(path, encoding="utf-8") as handle:
|
|
42
|
+
text = handle.read()
|
|
43
|
+
except (OSError, UnicodeDecodeError):
|
|
44
|
+
return {}
|
|
45
|
+
found = {}
|
|
46
|
+
for line in text.splitlines():
|
|
47
|
+
match = _LINE.match(line)
|
|
48
|
+
if not match:
|
|
49
|
+
continue
|
|
50
|
+
value = match.group(2).strip()
|
|
51
|
+
quote = value[:1]
|
|
52
|
+
if quote in ("'", '"') and value.find(quote, 1) > 0:
|
|
53
|
+
value = value[1:value.find(quote, 1)]
|
|
54
|
+
else:
|
|
55
|
+
value = re.sub(r"\s+#.*$", "", value)
|
|
56
|
+
found[match.group(1)] = value
|
|
57
|
+
return found
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def from_environment(options, dotenv=None):
|
|
61
|
+
"""options with what's missing filled from os.environ, then (when given)
|
|
62
|
+
from a .env file's ANALYTICS_* lines."""
|
|
63
|
+
filled = dict(options)
|
|
64
|
+
sources = [options_from(os.environ)]
|
|
65
|
+
if dotenv:
|
|
66
|
+
sources.append(options_from(read_dotenv(dotenv)))
|
|
67
|
+
for source in sources:
|
|
68
|
+
for key, value in source.items():
|
|
69
|
+
if filled.get(key) is None:
|
|
70
|
+
filled[key] = value
|
|
71
|
+
return filled
|
|
@@ -4,13 +4,15 @@ tracker counts those, and counting them here too would count them twice.
|
|
|
4
4
|
|
|
5
5
|
- track(): events, batched to POST /events/server-events in the background.
|
|
6
6
|
Each gets an event_uid, so a retried batch can be told apart from new events.
|
|
7
|
-
- identify(): links the browser's visit to your user ID (/events/identify)
|
|
7
|
+
- identify(): links the browser's visit to your user ID (/events/identify),
|
|
8
|
+
with optional traits about the user (plan, seats...).
|
|
8
9
|
- lead(): adds or updates a lead (/events/lead).
|
|
9
10
|
identify() and lead() wait for the answer (up to 5 seconds).
|
|
10
11
|
Pass session_id to link an event to the visit: the page sends
|
|
11
12
|
window.analyticsSession.getId() in the X-Analytics-Session header, and the
|
|
12
13
|
framework integrations give you platform.session_id(request).
|
|
13
14
|
"""
|
|
15
|
+
import math
|
|
14
16
|
import threading
|
|
15
17
|
import uuid
|
|
16
18
|
from datetime import datetime, timezone
|
|
@@ -23,6 +25,16 @@ FLUSH_EVERY = 2
|
|
|
23
25
|
RETRY = 15
|
|
24
26
|
|
|
25
27
|
|
|
28
|
+
def scalar_traits(traits):
|
|
29
|
+
"""The traits that are text, a finite number or True/False; {} when none is."""
|
|
30
|
+
if not isinstance(traits, dict):
|
|
31
|
+
return {}
|
|
32
|
+
return {
|
|
33
|
+
str(name): value for name, value in traits.items()
|
|
34
|
+
if isinstance(value, (str, bool, int)) or (isinstance(value, float) and math.isfinite(value))
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
26
38
|
class Analytics:
|
|
27
39
|
def __init__(self, core, **_):
|
|
28
40
|
self.core = core
|
|
@@ -79,11 +91,18 @@ class Analytics:
|
|
|
79
91
|
def _flush_job(self):
|
|
80
92
|
return FLUSH_EVERY if self.flush() else RETRY
|
|
81
93
|
|
|
82
|
-
def identify(self, user_id, session_id=""):
|
|
83
|
-
"""Links the visit to your user ID. True when stored.
|
|
94
|
+
def identify(self, user_id, session_id="", traits=None):
|
|
95
|
+
"""Links the visit to your user ID. True when stored. traits: what you
|
|
96
|
+
know about the user, name -> text, number or True/False
|
|
97
|
+
({"plan": "pro", "seats": 5}); anything else is left out, and the
|
|
98
|
+
collector cleans the names and values."""
|
|
84
99
|
if not user_id or not session_id or not self.core.is_on("analytics"):
|
|
85
100
|
return False
|
|
86
|
-
|
|
101
|
+
body = {"user_id": str(user_id), "session_id": session_id}
|
|
102
|
+
kept = scalar_traits(traits)
|
|
103
|
+
if kept:
|
|
104
|
+
body["traits"] = kept
|
|
105
|
+
res = self.core.send("analytics", "/events/identify", body=body)
|
|
87
106
|
return bool(res and res.ok)
|
|
88
107
|
|
|
89
108
|
def lead(self, fields, session_id=""):
|
|
@@ -1,11 +1,12 @@
|
|
|
1
|
-
"""Django: add the middleware first in MIDDLEWARE
|
|
1
|
+
"""Django: add the middleware first in MIDDLEWARE. That's all when the keys
|
|
2
|
+
are in the environment or in the project's .env (beside manage.py):
|
|
3
|
+
ANALYTICS_SITE_ID, ANALYTICS_SITE_SECRET and ANALYTICS_ENDPOINT.
|
|
2
4
|
|
|
3
5
|
MIDDLEWARE = ["analytics_platform.django.PlatformMiddleware", ...]
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
}
|
|
6
|
+
|
|
7
|
+
Options in code go in settings, and win over the environment:
|
|
8
|
+
|
|
9
|
+
ANALYTICS_PLATFORM = {"trust_proxy": 1, "errors": {"capture_logging": True}}
|
|
9
10
|
|
|
10
11
|
In your code, get_platform() is the same platform: get_platform().track(...).
|
|
11
12
|
The visitor's address is REMOTE_ADDR unless you set trust_proxy. The package
|
|
@@ -13,11 +14,13 @@ says it is Django (analytics-platform-django/<version>), so request rules
|
|
|
13
14
|
aimed at Django sites reach it. Every connection's queries are timed for
|
|
14
15
|
Server speed, under the request that ran them ("queries": False leaves them out).
|
|
15
16
|
"""
|
|
17
|
+
import os
|
|
16
18
|
import time
|
|
17
19
|
|
|
18
20
|
from django.conf import settings
|
|
19
21
|
from django.http import HttpResponse
|
|
20
22
|
|
|
23
|
+
from . import _env
|
|
21
24
|
from . import SESSION_HEADER, Platform, _inflight_end, _inflight_start, _queries_end, _queries_start
|
|
22
25
|
from .errors import current_request, current_trail
|
|
23
26
|
|
|
@@ -27,8 +30,13 @@ _platform = None
|
|
|
27
30
|
def get_platform():
|
|
28
31
|
global _platform
|
|
29
32
|
if _platform is None:
|
|
33
|
+
# Settings first, then ANALYTICS_* in the environment, then the
|
|
34
|
+
# ANALYTICS_* lines of the project's .env (beside manage.py), which is
|
|
35
|
+
# read, never loaded into the environment.
|
|
30
36
|
options = dict(getattr(settings, "ANALYTICS_PLATFORM", {}))
|
|
31
37
|
options.setdefault("client", "django")
|
|
38
|
+
base = getattr(settings, "BASE_DIR", None) or os.getcwd()
|
|
39
|
+
options = _env.from_environment(options, os.path.join(str(base), ".env"))
|
|
32
40
|
_platform = Platform(**options)
|
|
33
41
|
_connect_login_failed()
|
|
34
42
|
_watch_queries(_platform)
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""The one-line setup. Near the top of the module that makes your app:
|
|
2
|
+
|
|
3
|
+
from analytics_platform.start import platform
|
|
4
|
+
|
|
5
|
+
It reads ANALYTICS_SITE_ID, ANALYTICS_SITE_SECRET and ANALYTICS_ENDPOINT from
|
|
6
|
+
the environment, or else from the ANALYTICS_* lines of the .env in the folder
|
|
7
|
+
the server starts in (read, never written to os.environ).
|
|
8
|
+
|
|
9
|
+
Flask and FastAPI (any Starlette app): that's all. On an app's first request
|
|
10
|
+
it puts itself in front, as platform.init_flask(app) and
|
|
11
|
+
app.add_middleware(PlatformMiddleware, platform=platform) do by hand, so it
|
|
12
|
+
doesn't matter whether this import comes before or after the app is made. An
|
|
13
|
+
app that already has either by hand gets nothing added a second time, and a
|
|
14
|
+
Starlette app mounted inside another isn't counted twice.
|
|
15
|
+
|
|
16
|
+
Django: add "analytics_platform.django.PlatformMiddleware" first in
|
|
17
|
+
MIDDLEWARE instead, and use get_platform() from there.
|
|
18
|
+
|
|
19
|
+
The platform is made on first use (the first request, or the first
|
|
20
|
+
platform.track()), not on import: under Gunicorn or uWSGI with the app loaded
|
|
21
|
+
before the workers fork, its background threads then start in each worker,
|
|
22
|
+
not in the master. Use the same import wherever you call platform.track() and
|
|
23
|
+
the rest: it's one platform for the process.
|
|
24
|
+
"""
|
|
25
|
+
import os
|
|
26
|
+
import threading
|
|
27
|
+
|
|
28
|
+
from . import Platform, PlatformMiddleware, _env
|
|
29
|
+
|
|
30
|
+
_lock = threading.Lock()
|
|
31
|
+
_made = None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _platform():
|
|
35
|
+
global _made
|
|
36
|
+
if _made is None:
|
|
37
|
+
with _lock:
|
|
38
|
+
if _made is None:
|
|
39
|
+
_made = Platform(**_env.from_environment({}, os.path.join(os.getcwd(), ".env")))
|
|
40
|
+
return _made
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class _Platform:
|
|
44
|
+
"""Stands for the platform until its first use, then hands every
|
|
45
|
+
attribute to it."""
|
|
46
|
+
|
|
47
|
+
def __getattr__(self, name):
|
|
48
|
+
return getattr(_platform(), name)
|
|
49
|
+
|
|
50
|
+
def __repr__(self):
|
|
51
|
+
return "<analytics_platform.start platform%s>" % ("" if _made is not None else " (not made yet)")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
platform = _Platform()
|
|
55
|
+
|
|
56
|
+
_MARK = "_analytics_platform_started"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _hook_flask():
|
|
60
|
+
try:
|
|
61
|
+
from flask import Flask
|
|
62
|
+
except Exception:
|
|
63
|
+
return
|
|
64
|
+
if getattr(Flask.__call__, _MARK, False):
|
|
65
|
+
return
|
|
66
|
+
original = Flask.__call__
|
|
67
|
+
hook_lock = threading.Lock()
|
|
68
|
+
|
|
69
|
+
# The first request's own thread hooks the app in before Flask marks it
|
|
70
|
+
# as started (after that it refuses new before_request functions); other
|
|
71
|
+
# threads wait for it, so none gets past without it.
|
|
72
|
+
def __call__(self, environ, start_response):
|
|
73
|
+
if not self.__dict__.get(_MARK):
|
|
74
|
+
with hook_lock:
|
|
75
|
+
if not self.__dict__.get(_MARK):
|
|
76
|
+
try:
|
|
77
|
+
_platform().init_flask(self)
|
|
78
|
+
except Exception as error:
|
|
79
|
+
print("[analytics-platform] couldn't add itself to this Flask app; call platform.init_flask(app) by hand: %s" % error)
|
|
80
|
+
self.__dict__[_MARK] = True
|
|
81
|
+
return original(self, environ, start_response)
|
|
82
|
+
|
|
83
|
+
setattr(__call__, _MARK, True)
|
|
84
|
+
Flask.__call__ = __call__
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _hook_starlette():
|
|
88
|
+
try:
|
|
89
|
+
from starlette.applications import Starlette
|
|
90
|
+
from starlette.middleware import Middleware
|
|
91
|
+
except Exception:
|
|
92
|
+
return
|
|
93
|
+
if getattr(Starlette.__call__, _MARK, False):
|
|
94
|
+
return
|
|
95
|
+
original = Starlette.__call__
|
|
96
|
+
|
|
97
|
+
# Before the app builds its middleware stack, on its first call. Only the
|
|
98
|
+
# outermost app: a mounted one is called with scope["app"] already set by
|
|
99
|
+
# the app it's mounted in. Outermost of your own middleware, as
|
|
100
|
+
# add_middleware() called last puts it.
|
|
101
|
+
async def __call__(self, scope, receive, send):
|
|
102
|
+
if not self.__dict__.get(_MARK):
|
|
103
|
+
self.__dict__[_MARK] = True
|
|
104
|
+
outermost = "app" not in scope
|
|
105
|
+
by_hand = any(getattr(m, "cls", None) is PlatformMiddleware for m in getattr(self, "user_middleware", []))
|
|
106
|
+
if outermost and not by_hand and getattr(self, "middleware_stack", None) is None:
|
|
107
|
+
self.user_middleware.insert(0, Middleware(PlatformMiddleware, platform=platform))
|
|
108
|
+
await original(self, scope, receive, send)
|
|
109
|
+
|
|
110
|
+
setattr(__call__, _MARK, True)
|
|
111
|
+
Starlette.__call__ = __call__
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
# Each only when its framework is installed.
|
|
115
|
+
_hook_flask()
|
|
116
|
+
_hook_starlette()
|
|
117
|
+
|
|
118
|
+
__all__ = ["platform"]
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: analytics-platform
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.8
|
|
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
|
|
@@ -35,7 +35,42 @@ dependencies; Python 3.8 or later.
|
|
|
35
35
|
pip install analytics-platform
|
|
36
36
|
```
|
|
37
37
|
|
|
38
|
-
|
|
38
|
+
Put your keys in `.env` (site settings has all three, ready to copy), or in
|
|
39
|
+
your host's environment:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
ANALYTICS_SITE_ID=YOUR_SITE_ID
|
|
43
|
+
ANALYTICS_SITE_SECRET=YOUR_SITE_SECRET
|
|
44
|
+
ANALYTICS_ENDPOINT=https://YOUR_COLLECTOR_URL
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
The package reads only the `ANALYTICS_*` lines of the `.env` in the folder the
|
|
48
|
+
server starts in (Django: beside `manage.py`), and never writes them into
|
|
49
|
+
`os.environ`, so your own `.env` loading is unaffected. Behind Cloudflare, add
|
|
50
|
+
`ANALYTICS_TRUST_PROXY=cloudflare`; behind Nginx or a load balancer,
|
|
51
|
+
`ANALYTICS_TRUST_PROXY=1`. Several services on one site? Give each
|
|
52
|
+
`ANALYTICS_SERVER_NAME=api` (or `worker`, and so on).
|
|
53
|
+
|
|
54
|
+
## Flask and FastAPI: one line
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
from analytics_platform.start import platform
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Put it near the top of the module that makes your app. On the app's first
|
|
61
|
+
request, the package puts itself in front of it: Flask through
|
|
62
|
+
`init_flask(app)`, and FastAPI or any Starlette app through its middleware, as
|
|
63
|
+
the outermost of yours. It works whether the import is above or below
|
|
64
|
+
`app = Flask(...)`. An app that already has the platform by hand gets nothing
|
|
65
|
+
added a second time, and a FastAPI app mounted inside another isn't counted
|
|
66
|
+
twice.
|
|
67
|
+
|
|
68
|
+
Use the same import wherever you call `platform.track()` and the rest: it's
|
|
69
|
+
one platform for the whole process. It's made on first use, not on import, so
|
|
70
|
+
under Gunicorn or uWSGI with `--preload` its background threads start in each
|
|
71
|
+
worker, not in the master.
|
|
72
|
+
|
|
73
|
+
## Django: one line in settings
|
|
39
74
|
|
|
40
75
|
```python
|
|
41
76
|
# settings.py
|
|
@@ -43,36 +78,31 @@ MIDDLEWARE = [
|
|
|
43
78
|
"analytics_platform.django.PlatformMiddleware", # first
|
|
44
79
|
# ...
|
|
45
80
|
]
|
|
81
|
+
```
|
|
46
82
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
# "trust_proxy": "cloudflare", # every request through Cloudflare
|
|
53
|
-
}
|
|
83
|
+
That's all with the keys in `.env` or the environment. Options in code go in
|
|
84
|
+
`ANALYTICS_PLATFORM`, and win over the environment:
|
|
85
|
+
|
|
86
|
+
```python
|
|
87
|
+
ANALYTICS_PLATFORM = {"trust_proxy": 1, "errors": {"capture_logging": True}}
|
|
54
88
|
```
|
|
55
89
|
|
|
56
90
|
In your code: `from analytics_platform.django import get_platform`. The
|
|
57
91
|
package tells the collector it is a Django site (`analytics-platform-django/…`),
|
|
58
92
|
so attack patterns written for Django reach it.
|
|
59
93
|
|
|
60
|
-
##
|
|
94
|
+
## Setting it up in code instead
|
|
95
|
+
|
|
96
|
+
For options in code with Flask, FastAPI or another app (use this or the line
|
|
97
|
+
above, not both). Any key left out is read from `ANALYTICS_*`:
|
|
61
98
|
|
|
62
99
|
```python
|
|
63
100
|
from analytics_platform import create_platform, PlatformMiddleware
|
|
64
101
|
|
|
65
|
-
platform = create_platform(
|
|
66
|
-
endpoint="https://YOUR_COLLECTOR_URL")
|
|
67
|
-
app.add_middleware(PlatformMiddleware, platform=platform)
|
|
68
|
-
```
|
|
69
|
-
|
|
70
|
-
## Flask
|
|
102
|
+
platform = create_platform(errors={"capture_logging": True})
|
|
71
103
|
|
|
72
|
-
|
|
73
|
-
platform
|
|
74
|
-
endpoint="https://YOUR_COLLECTOR_URL")
|
|
75
|
-
platform.init_flask(app)
|
|
104
|
+
app.add_middleware(PlatformMiddleware, platform=platform) # FastAPI / Starlette
|
|
105
|
+
platform.init_flask(app) # Flask
|
|
76
106
|
```
|
|
77
107
|
|
|
78
108
|
Other WSGI apps: `app = platform.wsgi(app)`; other ASGI apps: `app = platform.asgi(app)`.
|
|
@@ -423,7 +453,7 @@ For what only your server knows for sure. Not pageviews: the tracker counts thos
|
|
|
423
453
|
session = platform.session_id(request) # the visit, from the X-Analytics-Session header
|
|
424
454
|
|
|
425
455
|
platform.track("purchase", {"numeric_value": "49.90", "currency": "EUR", "event_uid": f"order-{order.id}"}, session_id=session)
|
|
426
|
-
platform.identify(user.id, session_id=session)
|
|
456
|
+
platform.identify(user.id, session_id=session, traits={"plan": "pro", "seats": 5, "trial": False}) # traits are optional
|
|
427
457
|
platform.lead({"email": "ana@example.com", "name": "Ana", "status": "qualified"})
|
|
428
458
|
```
|
|
429
459
|
|
|
@@ -433,6 +463,13 @@ answer (up to 5 seconds). What you pass is taken as JSON when you pass it
|
|
|
433
463
|
a set a list, `NaN` null and anything else its `str()`, so one odd value can't
|
|
434
464
|
fail the batch it is sent in.
|
|
435
465
|
|
|
466
|
+
**User traits:** `identify()` takes optional `traits`, a dict of name to
|
|
467
|
+
text, number or `True`/`False`. Lists, dicts and other values are left out
|
|
468
|
+
(and `traits` is left off when nothing is left). The collector does the rest:
|
|
469
|
+
names are lowercased with spaces and dashes as `_` (up to 40 letters, digits
|
|
470
|
+
and `_`), text is cut at 200 characters, values that look like an email
|
|
471
|
+
address are dropped, and at most 20 are kept.
|
|
472
|
+
|
|
436
473
|
**Link to the visit:** send the tracker's session ID with your site's requests:
|
|
437
474
|
|
|
438
475
|
```js
|
{analytics_platform-0.3.6 → analytics_platform-0.3.8}/analytics_platform.egg-info/SOURCES.txt
RENAMED
|
@@ -2,6 +2,7 @@ LICENSE
|
|
|
2
2
|
README.md
|
|
3
3
|
pyproject.toml
|
|
4
4
|
analytics_platform/__init__.py
|
|
5
|
+
analytics_platform/_env.py
|
|
5
6
|
analytics_platform/analytics.py
|
|
6
7
|
analytics_platform/audit.py
|
|
7
8
|
analytics_platform/calls.py
|
|
@@ -25,6 +26,7 @@ analytics_platform/queues.py
|
|
|
25
26
|
analytics_platform/request_rules.py
|
|
26
27
|
analytics_platform/resources.py
|
|
27
28
|
analytics_platform/rq.py
|
|
29
|
+
analytics_platform/start.py
|
|
28
30
|
analytics_platform.egg-info/PKG-INFO
|
|
29
31
|
analytics_platform.egg-info/SOURCES.txt
|
|
30
32
|
analytics_platform.egg-info/dependency_links.txt
|
|
@@ -45,4 +47,5 @@ tests/test_redis.py
|
|
|
45
47
|
tests/test_request_rules.py
|
|
46
48
|
tests/test_resources.py
|
|
47
49
|
tests/test_rq_dramatiq.py
|
|
50
|
+
tests/test_start.py
|
|
48
51
|
tests/test_static_late.py
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "analytics-platform"
|
|
7
|
-
version = "0.3.
|
|
7
|
+
version = "0.3.8"
|
|
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"
|
|
@@ -394,6 +394,21 @@ class Payloads(unittest.TestCase):
|
|
|
394
394
|
self.assertEqual(event["at"], "2026-11-03")
|
|
395
395
|
self.assertIsNone(event["bad"])
|
|
396
396
|
|
|
397
|
+
def test_identify_passes_scalar_traits_through(self):
|
|
398
|
+
core = Quiet()
|
|
399
|
+
sent = []
|
|
400
|
+
core.send = lambda module, path, body=None, **_: sent.append(body) or mock.Mock(ok=True)
|
|
401
|
+
analytics = Analytics(core)
|
|
402
|
+
self.assertTrue(analytics.identify("u1", "s1"))
|
|
403
|
+
self.assertTrue(analytics.identify("u1", "s1", traits={"plan": "pro", "Seats": 5, "ratio": 0.5, "trial": False,
|
|
404
|
+
"tags": ["a"], "meta": {"a": 1}, "gone": None, "bad": float("nan")}))
|
|
405
|
+
self.assertTrue(analytics.identify("u1", "s1", {"tags": ["a"]}))
|
|
406
|
+
self.assertEqual(sent, [
|
|
407
|
+
{"user_id": "u1", "session_id": "s1"},
|
|
408
|
+
{"user_id": "u1", "session_id": "s1", "traits": {"plan": "pro", "Seats": 5, "ratio": 0.5, "trial": False}},
|
|
409
|
+
{"user_id": "u1", "session_id": "s1"},
|
|
410
|
+
])
|
|
411
|
+
|
|
397
412
|
def test_plain_handles_deep_and_odd_values(self):
|
|
398
413
|
deep = []
|
|
399
414
|
cur = deep
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""The one-line setup: keys from ANALYTICS_* or a .env file (never written to
|
|
2
|
+
os.environ), and analytics_platform.start putting itself in front of Flask
|
|
3
|
+
and FastAPI/Starlette apps on their first request, and Django's settings
|
|
4
|
+
block becoming optional. The framework tests skip where the framework isn't
|
|
5
|
+
installed; run them where Flask, FastAPI (with httpx) and Django are."""
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
import tempfile
|
|
9
|
+
import unittest
|
|
10
|
+
from unittest import mock
|
|
11
|
+
|
|
12
|
+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
|
13
|
+
|
|
14
|
+
from analytics_platform import Platform, PlatformMiddleware, _env # noqa: E402
|
|
15
|
+
|
|
16
|
+
SITE = "a" * 24
|
|
17
|
+
KEYS = {"ANALYTICS_SITE_ID": SITE, "ANALYTICS_SITE_SECRET": "s3cret", "ANALYTICS_ENDPOINT": "http://127.0.0.1:9"}
|
|
18
|
+
OFF = {name: False for name in ("firewall", "errors", "analytics", "performance", "crawlers", "forms", "headers", "audit",
|
|
19
|
+
"flags", "maintenance", "inventory", "posture", "resources", "proxy")}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def installed(name):
|
|
23
|
+
try:
|
|
24
|
+
__import__(name)
|
|
25
|
+
return True
|
|
26
|
+
except Exception:
|
|
27
|
+
return False
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class EnvTest(unittest.TestCase):
|
|
31
|
+
def test_options_from_the_environment(self):
|
|
32
|
+
found = _env.options_from({**KEYS, "ANALYTICS_TRUST_PROXY": "2", "ANALYTICS_SERVER_NAME": "api"})
|
|
33
|
+
self.assertEqual(found, {"site_id": SITE, "secret": "s3cret", "endpoint": "http://127.0.0.1:9",
|
|
34
|
+
"server_name": "api", "trust_proxy": 2})
|
|
35
|
+
self.assertEqual(_env.options_from({"ANALYTICS_TRUST_PROXY": "Cloudflare"})["trust_proxy"], "cloudflare")
|
|
36
|
+
self.assertEqual(_env.options_from({"ANALYTICS_FIREWALL_ENDPOINT": "https://x"})["endpoint"], "https://x")
|
|
37
|
+
self.assertEqual(_env.options_from({"ANALYTICS_TRUST_PROXY": "nonsense", "ANALYTICS_SITE_ID": " "}), {})
|
|
38
|
+
|
|
39
|
+
def test_dotenv_lines_are_read_and_never_loaded(self):
|
|
40
|
+
with tempfile.TemporaryDirectory() as folder:
|
|
41
|
+
path = os.path.join(folder, ".env")
|
|
42
|
+
with open(path, "w") as handle:
|
|
43
|
+
handle.write('DATABASE_URL=x\nANALYTICS_SITE_ID="%s"\nexport ANALYTICS_SITE_SECRET=from-file # note\n' % SITE)
|
|
44
|
+
with mock.patch.dict(os.environ, {"ANALYTICS_ENDPOINT": "http://127.0.0.1:9"}, clear=True):
|
|
45
|
+
filled = _env.from_environment({"secret": None}, path)
|
|
46
|
+
self.assertNotIn("ANALYTICS_SITE_SECRET", os.environ)
|
|
47
|
+
self.assertNotIn("DATABASE_URL", os.environ)
|
|
48
|
+
self.assertEqual((filled["site_id"], filled["secret"], filled["endpoint"]), (SITE, "from-file", "http://127.0.0.1:9"))
|
|
49
|
+
|
|
50
|
+
def test_the_environment_wins_over_the_file_and_given_options_over_both(self):
|
|
51
|
+
with tempfile.TemporaryDirectory() as folder:
|
|
52
|
+
path = os.path.join(folder, ".env")
|
|
53
|
+
with open(path, "w") as handle:
|
|
54
|
+
handle.write("ANALYTICS_SITE_SECRET=file\nANALYTICS_SERVER_NAME=file\n")
|
|
55
|
+
with mock.patch.dict(os.environ, {"ANALYTICS_SITE_SECRET": "env"}, clear=True):
|
|
56
|
+
filled = _env.from_environment({"server_name": "given"}, path)
|
|
57
|
+
self.assertEqual((filled["secret"], filled["server_name"]), ("env", "given"))
|
|
58
|
+
|
|
59
|
+
def test_platform_with_no_keys_reads_them_from_the_environment(self):
|
|
60
|
+
with mock.patch.dict(os.environ, KEYS, clear=True):
|
|
61
|
+
platform = Platform(**OFF)
|
|
62
|
+
self.assertTrue(platform.core.valid)
|
|
63
|
+
with mock.patch.dict(os.environ, {}, clear=True), mock.patch("builtins.print") as printed:
|
|
64
|
+
self.assertFalse(Platform(**OFF).core.valid)
|
|
65
|
+
self.assertIn("ANALYTICS_SITE_SECRET", printed.call_args[0][0])
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def started():
|
|
69
|
+
"""analytics_platform.start, with a fresh platform made from KEYS."""
|
|
70
|
+
from analytics_platform import start
|
|
71
|
+
start._made = None
|
|
72
|
+
return start
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@unittest.skipUnless(installed("flask"), "Flask isn't installed")
|
|
76
|
+
class FlaskTest(unittest.TestCase):
|
|
77
|
+
def test_hooks_itself_in_on_the_first_request_wherever_the_import_is(self):
|
|
78
|
+
from flask import Flask
|
|
79
|
+
app = Flask("before") # made before the import
|
|
80
|
+
app.get("/")(lambda: "ok")
|
|
81
|
+
with mock.patch.dict(os.environ, KEYS):
|
|
82
|
+
start = started()
|
|
83
|
+
response = app.test_client().get("/")
|
|
84
|
+
self.assertEqual(response.status_code, 200)
|
|
85
|
+
self.assertIs(app.extensions["analytics_platform"], start._made)
|
|
86
|
+
wrapped = app.wsgi_app
|
|
87
|
+
app.test_client().get("/")
|
|
88
|
+
self.assertIs(app.wsgi_app, wrapped, "wrapped once, not on every request")
|
|
89
|
+
|
|
90
|
+
def test_an_app_set_up_by_hand_is_left_alone(self):
|
|
91
|
+
from flask import Flask
|
|
92
|
+
with mock.patch.dict(os.environ, KEYS):
|
|
93
|
+
started()
|
|
94
|
+
own = Platform(**OFF)
|
|
95
|
+
app = Flask("by_hand")
|
|
96
|
+
app.get("/")(lambda: "ok")
|
|
97
|
+
own.init_flask(app)
|
|
98
|
+
wrapped = app.wsgi_app
|
|
99
|
+
self.assertEqual(app.test_client().get("/").status_code, 200)
|
|
100
|
+
self.assertIs(app.extensions["analytics_platform"], own)
|
|
101
|
+
self.assertIs(app.wsgi_app, wrapped)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@unittest.skipUnless(installed("fastapi") and installed("httpx"), "FastAPI or httpx isn't installed")
|
|
105
|
+
class FastAPITest(unittest.TestCase):
|
|
106
|
+
def test_outermost_of_your_middleware_and_not_in_a_mounted_app(self):
|
|
107
|
+
from fastapi import FastAPI
|
|
108
|
+
from fastapi.testclient import TestClient
|
|
109
|
+
from starlette.middleware.gzip import GZipMiddleware
|
|
110
|
+
with mock.patch.dict(os.environ, KEYS):
|
|
111
|
+
started()
|
|
112
|
+
app = FastAPI()
|
|
113
|
+
app.add_middleware(GZipMiddleware)
|
|
114
|
+
sub = FastAPI()
|
|
115
|
+
sub.get("/x")(lambda: {"ok": True})
|
|
116
|
+
app.mount("/sub", sub)
|
|
117
|
+
app.get("/")(lambda: {"ok": True})
|
|
118
|
+
client = TestClient(app)
|
|
119
|
+
self.assertEqual(client.get("/").status_code, 200)
|
|
120
|
+
self.assertEqual(client.get("/sub/x").status_code, 200)
|
|
121
|
+
self.assertEqual([m.cls for m in app.user_middleware], [PlatformMiddleware, GZipMiddleware])
|
|
122
|
+
self.assertEqual([m.cls for m in sub.user_middleware], [], "a mounted app isn't counted twice")
|
|
123
|
+
|
|
124
|
+
def test_an_app_with_it_by_hand_gets_no_second_one(self):
|
|
125
|
+
from fastapi import FastAPI
|
|
126
|
+
from fastapi.testclient import TestClient
|
|
127
|
+
with mock.patch.dict(os.environ, KEYS):
|
|
128
|
+
started()
|
|
129
|
+
app = FastAPI()
|
|
130
|
+
app.add_middleware(PlatformMiddleware, platform=Platform(**OFF))
|
|
131
|
+
app.get("/")(lambda: {"ok": True})
|
|
132
|
+
self.assertEqual(TestClient(app).get("/").status_code, 200)
|
|
133
|
+
self.assertEqual([m.cls for m in app.user_middleware], [PlatformMiddleware])
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@unittest.skipUnless(installed("django"), "Django isn't installed")
|
|
137
|
+
class DjangoTest(unittest.TestCase):
|
|
138
|
+
def test_no_settings_block_the_keys_from_the_projects_dotenv(self):
|
|
139
|
+
from django.conf import settings
|
|
140
|
+
with tempfile.TemporaryDirectory() as folder:
|
|
141
|
+
with open(os.path.join(folder, ".env"), "w") as handle:
|
|
142
|
+
handle.write("\n".join("%s=%s" % item for item in KEYS.items()))
|
|
143
|
+
if not settings.configured:
|
|
144
|
+
settings.configure(BASE_DIR=folder)
|
|
145
|
+
else:
|
|
146
|
+
settings.BASE_DIR = folder
|
|
147
|
+
from analytics_platform import django as integration
|
|
148
|
+
integration._platform = None
|
|
149
|
+
with mock.patch.dict(os.environ, {}, clear=True):
|
|
150
|
+
platform = integration.get_platform()
|
|
151
|
+
self.assertNotIn("ANALYTICS_SITE_SECRET", os.environ)
|
|
152
|
+
self.assertTrue(platform.core.valid)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
if __name__ == "__main__":
|
|
156
|
+
unittest.main()
|
|
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
|
|
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.3.6 → analytics_platform-0.3.8}/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
|
|
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
|