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.
Files changed (53) hide show
  1. {analytics_platform-0.3.6 → analytics_platform-0.3.8}/PKG-INFO +58 -21
  2. {analytics_platform-0.3.6 → analytics_platform-0.3.8}/README.md +57 -20
  3. {analytics_platform-0.3.6 → analytics_platform-0.3.8}/analytics_platform/__init__.py +19 -5
  4. analytics_platform-0.3.8/analytics_platform/_env.py +71 -0
  5. {analytics_platform-0.3.6 → analytics_platform-0.3.8}/analytics_platform/analytics.py +23 -4
  6. {analytics_platform-0.3.6 → analytics_platform-0.3.8}/analytics_platform/core.py +1 -1
  7. {analytics_platform-0.3.6 → analytics_platform-0.3.8}/analytics_platform/django.py +14 -6
  8. analytics_platform-0.3.8/analytics_platform/start.py +118 -0
  9. {analytics_platform-0.3.6 → analytics_platform-0.3.8}/analytics_platform.egg-info/PKG-INFO +58 -21
  10. {analytics_platform-0.3.6 → analytics_platform-0.3.8}/analytics_platform.egg-info/SOURCES.txt +3 -0
  11. {analytics_platform-0.3.6 → analytics_platform-0.3.8}/pyproject.toml +1 -1
  12. {analytics_platform-0.3.6 → analytics_platform-0.3.8}/tests/test_hardening.py +15 -0
  13. analytics_platform-0.3.8/tests/test_start.py +156 -0
  14. {analytics_platform-0.3.6 → analytics_platform-0.3.8}/LICENSE +0 -0
  15. {analytics_platform-0.3.6 → analytics_platform-0.3.8}/analytics_platform/audit.py +0 -0
  16. {analytics_platform-0.3.6 → analytics_platform-0.3.8}/analytics_platform/calls.py +0 -0
  17. {analytics_platform-0.3.6 → analytics_platform-0.3.8}/analytics_platform/celery.py +0 -0
  18. {analytics_platform-0.3.6 → analytics_platform-0.3.8}/analytics_platform/challenge.py +0 -0
  19. {analytics_platform-0.3.6 → analytics_platform-0.3.8}/analytics_platform/crawlers.py +0 -0
  20. {analytics_platform-0.3.6 → analytics_platform-0.3.8}/analytics_platform/dramatiq.py +0 -0
  21. {analytics_platform-0.3.6 → analytics_platform-0.3.8}/analytics_platform/errors.py +0 -0
  22. {analytics_platform-0.3.6 → analytics_platform-0.3.8}/analytics_platform/firewall.py +0 -0
  23. {analytics_platform-0.3.6 → analytics_platform-0.3.8}/analytics_platform/flags.py +0 -0
  24. {analytics_platform-0.3.6 → analytics_platform-0.3.8}/analytics_platform/forms.py +0 -0
  25. {analytics_platform-0.3.6 → analytics_platform-0.3.8}/analytics_platform/inventory.py +0 -0
  26. {analytics_platform-0.3.6 → analytics_platform-0.3.8}/analytics_platform/maintenance.py +0 -0
  27. {analytics_platform-0.3.6 → analytics_platform-0.3.8}/analytics_platform/performance.py +0 -0
  28. {analytics_platform-0.3.6 → analytics_platform-0.3.8}/analytics_platform/posture.py +0 -0
  29. {analytics_platform-0.3.6 → analytics_platform-0.3.8}/analytics_platform/proxy.py +0 -0
  30. {analytics_platform-0.3.6 → analytics_platform-0.3.8}/analytics_platform/queries.py +0 -0
  31. {analytics_platform-0.3.6 → analytics_platform-0.3.8}/analytics_platform/queues.py +0 -0
  32. {analytics_platform-0.3.6 → analytics_platform-0.3.8}/analytics_platform/request_rules.py +0 -0
  33. {analytics_platform-0.3.6 → analytics_platform-0.3.8}/analytics_platform/resources.py +0 -0
  34. {analytics_platform-0.3.6 → analytics_platform-0.3.8}/analytics_platform/rq.py +0 -0
  35. {analytics_platform-0.3.6 → analytics_platform-0.3.8}/analytics_platform.egg-info/dependency_links.txt +0 -0
  36. {analytics_platform-0.3.6 → analytics_platform-0.3.8}/analytics_platform.egg-info/top_level.txt +0 -0
  37. {analytics_platform-0.3.6 → analytics_platform-0.3.8}/setup.cfg +0 -0
  38. {analytics_platform-0.3.6 → analytics_platform-0.3.8}/tests/test_calls_queues.py +0 -0
  39. {analytics_platform-0.3.6 → analytics_platform-0.3.8}/tests/test_celery.py +0 -0
  40. {analytics_platform-0.3.6 → analytics_platform-0.3.8}/tests/test_challenge.py +0 -0
  41. {analytics_platform-0.3.6 → analytics_platform-0.3.8}/tests/test_checkin.py +0 -0
  42. {analytics_platform-0.3.6 → analytics_platform-0.3.8}/tests/test_crawlers.py +0 -0
  43. {analytics_platform-0.3.6 → analytics_platform-0.3.8}/tests/test_errors.py +0 -0
  44. {analytics_platform-0.3.6 → analytics_platform-0.3.8}/tests/test_firewall.py +0 -0
  45. {analytics_platform-0.3.6 → analytics_platform-0.3.8}/tests/test_limits.py +0 -0
  46. {analytics_platform-0.3.6 → analytics_platform-0.3.8}/tests/test_page_limits.py +0 -0
  47. {analytics_platform-0.3.6 → analytics_platform-0.3.8}/tests/test_proxy.py +0 -0
  48. {analytics_platform-0.3.6 → analytics_platform-0.3.8}/tests/test_queries.py +0 -0
  49. {analytics_platform-0.3.6 → analytics_platform-0.3.8}/tests/test_redis.py +0 -0
  50. {analytics_platform-0.3.6 → analytics_platform-0.3.8}/tests/test_request_rules.py +0 -0
  51. {analytics_platform-0.3.6 → analytics_platform-0.3.8}/tests/test_resources.py +0 -0
  52. {analytics_platform-0.3.6 → analytics_platform-0.3.8}/tests/test_rq_dramatiq.py +0 -0
  53. {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.6
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
- ## Django
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
- ANALYTICS_PLATFORM = {
48
- "site_id": "YOUR_SITE_ID",
49
- "secret": os.environ["ANALYTICS_SITE_SECRET"], # site settings; keep it on the server
50
- "endpoint": "https://YOUR_COLLECTOR_URL",
51
- # "trust_proxy": 1, # behind Nginx or a load balancer
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
- ## FastAPI / Starlette
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(site_id="YOUR_SITE_ID", secret=os.environ["ANALYTICS_SITE_SECRET"],
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
- ```python
73
- platform = create_platform(site_id="YOUR_SITE_ID", secret=os.environ["ANALYTICS_SITE_SECRET"],
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
- ## Django
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
- ANALYTICS_PLATFORM = {
22
- "site_id": "YOUR_SITE_ID",
23
- "secret": os.environ["ANALYTICS_SITE_SECRET"], # site settings; keep it on the server
24
- "endpoint": "https://YOUR_COLLECTOR_URL",
25
- # "trust_proxy": 1, # behind Nginx or a load balancer
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
- ## FastAPI / Starlette
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(site_id="YOUR_SITE_ID", secret=os.environ["ANALYTICS_SITE_SECRET"],
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
- ```python
47
- platform = create_platform(site_id="YOUR_SITE_ID", secret=os.environ["ANALYTICS_SITE_SECRET"],
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); trust_proxy (how to find the visitor's address: None
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; nothing is sent and every request passes")
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
- res = self.core.send("analytics", "/events/identify", body={"user_id": str(user_id), "session_id": session_id})
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=""):
@@ -23,7 +23,7 @@ import urllib.error
23
23
  import urllib.parse
24
24
  import urllib.request
25
25
 
26
- VERSION = "0.3.6"
26
+ VERSION = "0.3.8"
27
27
  TIMEOUT = 5
28
28
  MIN_SYNC = 30
29
29
  RETRY = 60
@@ -1,11 +1,12 @@
1
- """Django: add the middleware first in MIDDLEWARE and the options to settings.
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
- ANALYTICS_PLATFORM = {
5
- "site_id": "...",
6
- "secret": os.environ["ANALYTICS_SITE_SECRET"],
7
- "endpoint": "https://...",
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.6
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
- ## Django
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
- ANALYTICS_PLATFORM = {
48
- "site_id": "YOUR_SITE_ID",
49
- "secret": os.environ["ANALYTICS_SITE_SECRET"], # site settings; keep it on the server
50
- "endpoint": "https://YOUR_COLLECTOR_URL",
51
- # "trust_proxy": 1, # behind Nginx or a load balancer
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
- ## FastAPI / Starlette
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(site_id="YOUR_SITE_ID", secret=os.environ["ANALYTICS_SITE_SECRET"],
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
- ```python
73
- platform = create_platform(site_id="YOUR_SITE_ID", secret=os.environ["ANALYTICS_SITE_SECRET"],
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
@@ -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.6"
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()