analytics-platform 0.3.3__tar.gz → 0.3.5__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 (56) hide show
  1. analytics_platform-0.3.5/PKG-INFO +479 -0
  2. analytics_platform-0.3.5/README.md +453 -0
  3. {analytics_platform-0.3.3 → analytics_platform-0.3.5}/analytics_platform/__init__.py +376 -35
  4. {analytics_platform-0.3.3 → analytics_platform-0.3.5}/analytics_platform/analytics.py +8 -4
  5. {analytics_platform-0.3.3 → analytics_platform-0.3.5}/analytics_platform/audit.py +4 -2
  6. analytics_platform-0.3.5/analytics_platform/calls.py +253 -0
  7. analytics_platform-0.3.5/analytics_platform/celery.py +199 -0
  8. analytics_platform-0.3.5/analytics_platform/challenge.py +167 -0
  9. {analytics_platform-0.3.3 → analytics_platform-0.3.5}/analytics_platform/core.py +212 -15
  10. {analytics_platform-0.3.3 → analytics_platform-0.3.5}/analytics_platform/crawlers.py +7 -0
  11. {analytics_platform-0.3.3 → analytics_platform-0.3.5}/analytics_platform/django.py +88 -13
  12. analytics_platform-0.3.5/analytics_platform/dramatiq.py +68 -0
  13. {analytics_platform-0.3.3 → analytics_platform-0.3.5}/analytics_platform/errors.py +173 -35
  14. analytics_platform-0.3.5/analytics_platform/firewall.py +742 -0
  15. {analytics_platform-0.3.3 → analytics_platform-0.3.5}/analytics_platform/maintenance.py +9 -11
  16. analytics_platform-0.3.5/analytics_platform/performance.py +178 -0
  17. {analytics_platform-0.3.3 → analytics_platform-0.3.5}/analytics_platform/posture.py +8 -8
  18. {analytics_platform-0.3.3 → analytics_platform-0.3.5}/analytics_platform/proxy.py +77 -8
  19. analytics_platform-0.3.5/analytics_platform/queries.py +1064 -0
  20. analytics_platform-0.3.5/analytics_platform/queues.py +188 -0
  21. {analytics_platform-0.3.3 → analytics_platform-0.3.5}/analytics_platform/request_rules.py +160 -27
  22. {analytics_platform-0.3.3 → analytics_platform-0.3.5}/analytics_platform/resources.py +309 -2
  23. analytics_platform-0.3.5/analytics_platform/rq.py +193 -0
  24. analytics_platform-0.3.5/analytics_platform.egg-info/PKG-INFO +479 -0
  25. {analytics_platform-0.3.3 → analytics_platform-0.3.5}/analytics_platform.egg-info/SOURCES.txt +17 -1
  26. {analytics_platform-0.3.3 → analytics_platform-0.3.5}/pyproject.toml +4 -1
  27. analytics_platform-0.3.5/tests/test_calls_queues.py +539 -0
  28. analytics_platform-0.3.5/tests/test_celery.py +206 -0
  29. analytics_platform-0.3.5/tests/test_challenge.py +407 -0
  30. analytics_platform-0.3.5/tests/test_checkin.py +87 -0
  31. analytics_platform-0.3.5/tests/test_firewall.py +96 -0
  32. analytics_platform-0.3.5/tests/test_hardening.py +535 -0
  33. analytics_platform-0.3.5/tests/test_limits.py +154 -0
  34. analytics_platform-0.3.5/tests/test_page_limits.py +187 -0
  35. analytics_platform-0.3.5/tests/test_queries.py +259 -0
  36. analytics_platform-0.3.5/tests/test_rq_dramatiq.py +302 -0
  37. analytics_platform-0.3.5/tests/test_static_late.py +285 -0
  38. analytics_platform-0.3.3/PKG-INFO +0 -267
  39. analytics_platform-0.3.3/README.md +0 -241
  40. analytics_platform-0.3.3/analytics_platform/firewall.py +0 -316
  41. analytics_platform-0.3.3/analytics_platform/performance.py +0 -98
  42. analytics_platform-0.3.3/analytics_platform.egg-info/PKG-INFO +0 -267
  43. analytics_platform-0.3.3/tests/test_checkin.py +0 -29
  44. analytics_platform-0.3.3/tests/test_firewall.py +0 -56
  45. {analytics_platform-0.3.3 → analytics_platform-0.3.5}/LICENSE +0 -0
  46. {analytics_platform-0.3.3 → analytics_platform-0.3.5}/analytics_platform/flags.py +0 -0
  47. {analytics_platform-0.3.3 → analytics_platform-0.3.5}/analytics_platform/forms.py +0 -0
  48. {analytics_platform-0.3.3 → analytics_platform-0.3.5}/analytics_platform/inventory.py +0 -0
  49. {analytics_platform-0.3.3 → analytics_platform-0.3.5}/analytics_platform.egg-info/dependency_links.txt +0 -0
  50. {analytics_platform-0.3.3 → analytics_platform-0.3.5}/analytics_platform.egg-info/top_level.txt +0 -0
  51. {analytics_platform-0.3.3 → analytics_platform-0.3.5}/setup.cfg +0 -0
  52. {analytics_platform-0.3.3 → analytics_platform-0.3.5}/tests/test_crawlers.py +0 -0
  53. {analytics_platform-0.3.3 → analytics_platform-0.3.5}/tests/test_errors.py +0 -0
  54. {analytics_platform-0.3.3 → analytics_platform-0.3.5}/tests/test_proxy.py +0 -0
  55. {analytics_platform-0.3.3 → analytics_platform-0.3.5}/tests/test_request_rules.py +0 -0
  56. {analytics_platform-0.3.3 → analytics_platform-0.3.5}/tests/test_resources.py +0 -0
@@ -0,0 +1,479 @@
1
+ Metadata-Version: 2.4
2
+ Name: analytics-platform
3
+ Version: 0.3.5
4
+ Summary: Firewall, error monitoring and server analytics for your Python server, run from your analytics dashboard.
5
+ Author: steviec87
6
+ License-Expression: MIT
7
+ Keywords: firewall,waf,rate-limit,error-monitoring,analytics,server-monitoring,django,flask,fastapi,celery,rq,dramatiq,wsgi,asgi
8
+ Classifier: Development Status :: 4 - Beta
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: Intended Audience :: System Administrators
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.8
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Framework :: Django
16
+ Classifier: Framework :: Flask
17
+ Classifier: Framework :: FastAPI
18
+ Classifier: Topic :: Internet :: WWW/HTTP
19
+ Classifier: Topic :: Internet :: Log Analysis
20
+ Classifier: Topic :: Security
21
+ Classifier: Topic :: System :: Monitoring
22
+ Requires-Python: >=3.8
23
+ Description-Content-Type: text/markdown
24
+ License-File: LICENSE
25
+ Dynamic: license-file
26
+
27
+ # analytics-platform (Python)
28
+
29
+ One package for your Python server, run from your analytics dashboard: a
30
+ **firewall**, **error monitoring** and **server analytics** (payments,
31
+ sign-ups, leads). Django, Flask, FastAPI and any WSGI or ASGI app. No
32
+ dependencies; Python 3.8 or later.
33
+
34
+ ```bash
35
+ pip install analytics-platform
36
+ ```
37
+
38
+ ## Django
39
+
40
+ ```python
41
+ # settings.py
42
+ MIDDLEWARE = [
43
+ "analytics_platform.django.PlatformMiddleware", # first
44
+ # ...
45
+ ]
46
+
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
+ }
54
+ ```
55
+
56
+ In your code: `from analytics_platform.django import get_platform`. The
57
+ package tells the collector it is a Django site (`analytics-platform-django/…`),
58
+ so attack patterns written for Django reach it.
59
+
60
+ ## FastAPI / Starlette
61
+
62
+ ```python
63
+ from analytics_platform import create_platform, PlatformMiddleware
64
+
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
71
+
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)
76
+ ```
77
+
78
+ Other WSGI apps: `app = platform.wsgi(app)`; other ASGI apps: `app = platform.asgi(app)`.
79
+
80
+ ## Celery, RQ and Dramatiq
81
+
82
+ ```python
83
+ platform.init_celery(app) # Celery: where the app is made
84
+ platform.init_dramatiq(broker) # Dramatiq: after dramatiq.set_broker(broker)
85
+ PlatformWorker = platform.rq_worker() # RQ: rq worker -w jobs.PlatformWorker
86
+ ```
87
+
88
+ Every task, message or job then runs as a [job](#error-monitoring) named
89
+ after the task, actor or function: its errors carry the route
90
+ `/(job) shop.tasks.charge`, one that raises is sent once (status 500), and
91
+ each run is timed (Server speed → Jobs). Prefork, solo, threads, gevent and
92
+ eventlet pools all work (Dramatiq's `dramatiq-gevent` too), and so does
93
+ Celery's `task_always_eager`.
94
+
95
+ - A failure that will be retried is timed as failed but not sent: the error
96
+ is sent when the last attempt fails. Celery's `Ignore` counts as done,
97
+ `Reject` as failed.
98
+ - A run cut short (`SystemExit`, a hard time limit, the worker stopping
99
+ mid-task) is timed as failed. Time limits are sent as errors; `SystemExit`
100
+ isn't.
101
+ - A Celery or Dramatiq process killed outright (`SIGKILL`, out of memory)
102
+ can't be timed: nothing of the run is left to say how long it took. RQ's
103
+ worker outlives the process it runs a job in, so it sends and times that
104
+ job itself (`WorkHorseKilled`).
105
+ - What's waiting is sent as each worker process ends. RQ runs each job in a
106
+ process of its own, so it sends after every job; only the worker checks
107
+ in (Health → All systems), not a process per job.
108
+ - Don't also wrap a task in `platform.job()`: it would be timed twice.
109
+ - Each queue's backlog: [`platform.watch_queue(...)`](#job-queues).
110
+
111
+ ## Modules
112
+
113
+ | Module | What it does | Default |
114
+ |---|---|---|
115
+ | `firewall` | Blocks addresses, known criminal networks, shared blocks and floods (Security → Firewall) | on |
116
+ | `firewall`'s attack patterns | Rules that match the *request* (its path, query string and user agent), not just the address, so an attack is turned away before your code runs — a package with a known hole stays safe until you update it. They come with the firewall's rules and are managed on the Security page (Firewall → Attack patterns); a pattern a real visitor might trip only watches until you promote it. Nothing to configure (protocol: `sdk/protocols/firewall.md`) | with the firewall |
117
+ | `proxy` | Serves the tracker and takes visits under your own domain, so ad blockers can't hide those visitors | on |
118
+ | `errors` | Sends your server's errors to the Errors report | on |
119
+ | `analytics` | `track()`, `identify()` and `lead()` from your server | on |
120
+ | `audit` | `platform.audit("user.role_changed", actor=..., target=..., request=...)`; Django logins and logouts on their own | on |
121
+ | `flags` | `platform.is_enabled("new-checkout", user=user.id)`: features on or off from the dashboard | on |
122
+ | `maintenance` | A "back soon" page while maintenance mode is on in the dashboard | on |
123
+ | `headers` | Adds the security headers you pick on the Security page; your own headers win | on |
124
+ | `forms` | Checks forms before you accept them: `platform.check_form(request)` | on |
125
+ | `performance` | Response times and errors of every route (Health → Server speed): Django, Flask and FastAPI route patterns, the database queries each one ran, the other services it waited on, and your job queues' backlog (below) | on |
126
+ | `crawlers` | Which search engines and AI bots fetch your pages (Off-site → Crawlers). Django counts them for you; elsewhere call `platform.record_crawler(...)` | on |
127
+ | `inventory` | The installed packages and Python version, once a day, so the Security check can flag known security holes (`{"host": ...}`: the server's name) | on |
128
+ | `posture` | How the server is set up, once a day, for the Security check: Django's `DEBUG`, `SECRET_KEY`, `ALLOWED_HOSTS` and cookie settings (when it's a Django site), whether the app runs as root, code anyone on the machine can write, and files in the folders you serve to the web that shouldn't be public (a dump, a `.env`, a `.bak`). The dashboard then asks your site for each of those files and tells you whether it really is downloadable. Reads only, and sends no file's contents. `{"roots": ["public", {"dir": "uploads", "prefix": "files"}]}` (default: Django's `STATIC_ROOT` and `MEDIA_ROOT`, reported at their `STATIC_URL` / `MEDIA_URL`), `{"checks": fn}` for checks of your own (protocol: `sdk/protocols/posture.md`) | on |
129
+
130
+ Each takes `True`, `False` or its options as a dict:
131
+
132
+ ```python
133
+ create_platform(site_id=..., secret=..., endpoint=...,
134
+ firewall={"mode": "monitor", "cache_file": "/tmp/firewall-rules.json"},
135
+ errors={"capture_uncaught": False},
136
+ analytics=False)
137
+ ```
138
+
139
+ **Switch modules off in the dashboard** (Security → Server package) without a
140
+ deploy: the package checks about once a minute. Modules are independent: one
141
+ failing to start, or the collector being down, never stops the others, and
142
+ never slows your requests. Background work runs in daemon threads, which start
143
+ again in each worker after a fork (gunicorn, uWSGI).
144
+
145
+ ## Firewall
146
+
147
+ Rules, monitor or block mode and reports are in the dashboard (Security →
148
+ Firewall). New sites start in monitor mode.
149
+
150
+ Options: `mode` (`"monitor"` or `"block"`, overrides the dashboard),
151
+ `cache_file` (keep rules between restarts), `trust_proxy`, `counter`.
152
+
153
+ **The visitor's address:** behind a proxy, every request comes from the proxy
154
+ unless you set `trust_proxy`: `1` for one proxy or load balancer, `2` for
155
+ Cloudflare in front of a load balancer, `"cloudflare"` when every request comes
156
+ through Cloudflare (`CF-Connecting-IP` is believed only from Cloudflare's own
157
+ addresses: anyone who reaches your server directly can send it).
158
+ `X-Forwarded-For` is read from the right, as each proxy appends what it saw;
159
+ a port on an entry (`203.0.113.5:54321`, `[2001:db8::1]:443`, as Azure sends
160
+ them) is dropped, and an entry that isn't an address (`unknown`, a name)
161
+ means the connection's own address is used rather than no check at all.
162
+
163
+ When something in front of the package already works out the address, count
164
+ each proxy once:
165
+
166
+ - **gunicorn** never changes `REMOTE_ADDR` (`--forwarded-allow-ips` is about
167
+ `X-Forwarded-Proto` only): set `trust_proxy`.
168
+ - **Uvicorn** (`--proxy-headers`, on by default, trusting the addresses in
169
+ `--forwarded-allow-ips`) puts the visitor's address in place itself, and
170
+ leaves the header as it was. Leave `trust_proxy` unset when
171
+ `--forwarded-allow-ips` lists your proxies' addresses. With
172
+ `--forwarded-allow-ips='*'` (`FORWARDED_ALLOW_IPS=*`, common in Docker images)
173
+ Uvicorn takes the **leftmost** entry, which the visitor writes: there, set
174
+ `trust_proxy` to your number of proxies, and the package reads the header
175
+ itself from the right.
176
+ - **werkzeug's `ProxyFix`**: wrapped inside the package's middleware
177
+ (`app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1)` before `init_flask`), the
178
+ package uses its `x_for` when `trust_proxy` is unset; wrapped outside, it has
179
+ already set `REMOTE_ADDR` and unset `trust_proxy` uses that.
180
+ - **Apache's `mod_remoteip`** (`mod_wsgi`) sets `REMOTE_ADDR` to the visitor
181
+ and *removes the entries it used* from `X-Forwarded-For`, leaving only what
182
+ the visitor wrote. Leave `trust_proxy` unset there: counting the same proxy
183
+ again would believe the visitor's own entry.
184
+ - **Django** reads no forwarding header of its own: as gunicorn or Uvicorn
185
+ above, whichever runs it.
186
+
187
+ **What the rate limit counts:** requests per IPv4 address, or per IPv6
188
+ **/64** (one machine usually holds a whole /64, so counting addresses would let
189
+ it start afresh on every request); `::ffff:a.b.c.d` counts as `a.b.c.d`. The
190
+ same goes for bans and failed logins. Static files (`.css`, `.js`, images,
191
+ fonts, video, `.pdf`, `.wasm`, and anything under `/static/`) aren't counted,
192
+ so a page with 40 assets is one hit, not 41. A full table of counts (100,000)
193
+ drops its oldest entries, never everyone's.
194
+
195
+ **Rate limit with several workers:** each worker counts on its own, so the
196
+ limit is per worker. For one count, pass a shared counter:
197
+
198
+ ```python
199
+ def counter(key, ttl):
200
+ hits = redis.incr(key)
201
+ if hits == 1:
202
+ redis.expire(key, ttl)
203
+ return hits
204
+
205
+ create_platform(..., firewall={"counter": counter})
206
+ ```
207
+
208
+ **Bot protection:** Django's failed logins are counted on their own; elsewhere
209
+ call `platform.login_failed(request)` when a login fails. Past the site's limit
210
+ the address is banned here straight away and on your other servers within a
211
+ minute or two. Floods (over the rate limit) are banned the same way.
212
+
213
+
214
+ **Probes.** Requests for files no visitor ever asks for (`/.env`, `/.git/…`,
215
+ backups: the list comes with the rules) are rule `probe`; in block mode the
216
+ address is also banned on this server, and the collector bans it on the
217
+ site's other servers.
218
+
219
+ **Browser check** (paid plans, switched on in the dashboard). A visitor's
220
+ first page of the day is a short "checking your browser" page that sets a
221
+ cookie with JavaScript and reloads; after that the site answers as usual for
222
+ about a day. Only a `GET` or `HEAD` that asks for a web page is checked: forms,
223
+ webhooks, payment callbacks, API calls (`Accept: application/json`), the paths
224
+ you exclude (`/api/`, …), your allowed addresses, search engines and the AI
225
+ crawlers that bring visits never see it. It stops cheap floods — scripts that
226
+ don't run JavaScript — and nothing more: a real browser, or a bot driving one,
227
+ gets through. It's a speed bump against scripts that don't run JavaScript: a
228
+ bot written for this product can read the pass from the page, which is why
229
+ the pass is bound to the visitor's address and the day. The cookie, `__sg_pass`, holds a signed day and nothing about the
230
+ visitor; it exists only to keep the site reachable, so it is strictly necessary
231
+ and needs no consent. Checked requests show on the Security page as rule
232
+ `challenge`.
233
+
234
+ ## Error monitoring
235
+
236
+ Uncaught exceptions are sent on their own; request errors through the
237
+ middleware (Django, Flask, FastAPI). Errors you catch:
238
+
239
+ ```python
240
+ try:
241
+ charge_card(order)
242
+ except PaymentError as error:
243
+ platform.capture_exception(error, status=502)
244
+ raise
245
+ ```
246
+
247
+ Exceptions your code only logs: `errors={"capture_logging": True}` sends an
248
+ exception logged at ERROR or above (`logger.exception(...)`, or
249
+ `exc_info=True`); it is still logged. An exception is sent once however many
250
+ of these see it, and one logged inside a Django, Flask or ASGI request keeps
251
+ that request's route. A batch the collector couldn't take is tried once more
252
+ a few seconds later. A visitor who hangs up mid-request (`BrokenPipeError`,
253
+ `ConnectionResetError`, Starlette's `ClientDisconnect`, Django's
254
+ `UnreadablePostError`) isn't sent as a server error. Messages are cut at
255
+ 2,000 characters; the traceback keeps the detail.
256
+
257
+ What still can't be sent waits in a file in the temp folder and is sent when
258
+ it can be, after a restart too; each error keeps the time it happened.
259
+
260
+ An error in a request or job also shows the steps before it. Add them with
261
+ `platform.add_breadcrumb("Order saved")`; a step added outside a request or job
262
+ is dropped. Outgoing calls aren't recorded on their own, since Python has no
263
+ public way to see them.
264
+
265
+ Background jobs, as a decorator or a `with` block (sync or async):
266
+
267
+ ```python
268
+ @platform.job("send-receipts")
269
+ def send_receipts(): ...
270
+ ```
271
+
272
+ Their errors carry the job's name as the route, a job that raises is sent
273
+ (status 500) and raised on, and each run is timed (Server speed → Jobs).
274
+ Several services on one site: `Platform(..., server_name="worker")`.
275
+
276
+ **Several apps on one site** (microservices, a worker, a Node service next to
277
+ this one): install the package in each, with the same `site_id`, `secret` and
278
+ `endpoint`, and a `server_name` of its own. Errors, Server speed, Crawlers and
279
+ Health → Updates then tell the apps apart, and Health → All systems lists
280
+ each with a light: the package checks in about once a minute, so an app that
281
+ stops reads "Stopped" within ten minutes (`sdk/protocols/checkin.md`).
282
+ Without one, each app is named
283
+ after its machine: two apps on one machine are reported as one, and only the
284
+ last package list sent is kept. A service only your other apps call takes
285
+ `firewall=False, proxy=False, maintenance=False` (its requests all come from
286
+ your own apps, so the rate limit would see one visitor).
287
+
288
+ **Several apps on one machine**: leave `resources` on in all of them. The
289
+ machine's CPU, memory and disks are shown once, and each app (each gunicorn
290
+ or Celery worker process too) adds what is its own: its container's memory
291
+ and CPU caps and how close it is to them, how often the cap throttled it,
292
+ processes the kernel killed for memory, open files against the limit, the
293
+ most requests in progress at once, and how long it has run. On Linux the
294
+ machine's waiting time (`/proc/pressure`), connections refused before the
295
+ app accepted them and the connection-tracking table come with them. Python
296
+ sends no heap or garbage-collection figures. A figure the machine won't give
297
+ (no cap, no PSI, not Linux) is left out, never sent as 0 (protocol:
298
+ `sdk/protocols/resources.md`, "Limits").
299
+
300
+ Options: `server_name`, `root` (traceback paths relative to it),
301
+ `capture_uncaught`, `capture_logging`, `spool_dir` (where unsent errors wait;
302
+ `False` for none), `before_send(event, error)` (change an error, or return
303
+ `None` to drop it).
304
+
305
+ ## Database queries
306
+
307
+ Server speed also shows which queries a route spends its time on (Health →
308
+ Server speed → Queries): per route and query, how many ran, how many failed and
309
+ how long they took. Every value is taken out of a query on your server before
310
+ it is counted (`WHERE email = ?`, `IN (?)`), and bound parameters are never
311
+ read, so no data leaves it.
312
+
313
+ | Library | Setup |
314
+ |---|---|
315
+ | Django | nothing: every connection, under the request or job that ran the query |
316
+ | SQLAlchemy | nothing: every engine (Flask-SQLAlchemy too) |
317
+ | pymongo | nothing, but **create the platform before your `MongoClient`**: pymongo only reports to clients made after the package is listening |
318
+ | Any other DB-API driver (psycopg, psycopg2, mysqlclient, PyMySQL, sqlite3...) | `conn = platform.watch_database(psycopg.connect(...))`, and use `conn` as before |
319
+ | ClickHouse: clickhouse-connect, clickhouse-driver | `client = platform.watch_database(clickhouse_connect.get_client(...))` (or a `clickhouse_driver.Client`), and use `client` as before |
320
+
321
+ A query outside any request or job is counted as `JOB (background)`. Each
322
+ query is counted once: when SQLAlchemy or Django runs it over a watched
323
+ connection, they count it and the connection doesn't. It is on with
324
+ `performance`, and switched off with it in the dashboard; `queries=False`
325
+ keeps Server speed and leaves queries out (protocol: `sdk/protocols/queries.md`).
326
+
327
+ ClickHouse: clickhouse-connect's `query()` (and `query_df()`, `query_np()`,
328
+ `query_arrow()`, `raw_query()`) reads the whole result before it returns, so
329
+ its time is the result's too; `command()` and the inserts are timed until
330
+ they answer. clickhouse-driver's `execute()` likewise. The streaming
331
+ queries (`query_rows_stream()`, `execute_iter()`) aren't timed. Only the
332
+ client you pass is wrapped; the library itself is left alone.
333
+
334
+ ## Outgoing calls
335
+
336
+ Health → Server speed → Calls shows how long each route waited on other
337
+ services (a payment provider, an email API, your own microservices) by the
338
+ host it called (`api.stripe.com`), with how many calls failed. One line for
339
+ each client, where you make it:
340
+
341
+ ```python
342
+ session = platform.watch_http(requests.Session()) # requests
343
+ client = platform.watch_http(httpx.Client()) # httpx; AsyncClient too
344
+
345
+ # httpx, with calls that got no answer counted too:
346
+ client = httpx.Client(transport=platform.watch_http(httpx.HTTPTransport()))
347
+ ```
348
+
349
+ - **Only the host is sent.** A call's path, query string, headers and body
350
+ never leave your server: paths carry IDs and keys.
351
+ - A call is timed from being sent to its response's headers arriving. A
352
+ call that got no answer (refused, timed out), or a 5xx, is a failed one; a
353
+ 4xx is the other side's answer, not a failure. A call your code cancels
354
+ isn't counted.
355
+ - requests: each adapter the session has mounted is wrapped, keeping its
356
+ retries and pool settings; mount your own adapters before watching.
357
+ - httpx: `watch_http(client)` adds event hooks, which see every answered
358
+ call but not one that got no answer at all. A watched transport
359
+ (`AsyncHTTPTransport` for an `AsyncClient`) sees those too; with both on
360
+ one client a call is counted once.
361
+ - urllib3 and `urllib.request` have no public hook, so their calls aren't
362
+ counted (and nothing of theirs is patched).
363
+ - Calls are filed as queries are: under the request, the job, or
364
+ `JOB (background)`. The package's own calls to us are left out.
365
+ - They ride on Server speed's switch; `calls=False` leaves them out
366
+ (protocol: `sdk/protocols/calls.md`).
367
+
368
+ ## Job queues
369
+
370
+ Health → Server speed → Jobs shows each queue's backlog once a minute: jobs
371
+ waiting, running, delayed and failed, how long the oldest has waited, and
372
+ jobs added against jobs finished, so a queue falling behind shows before the
373
+ emails go out an hour late.
374
+
375
+ ```python
376
+ platform.watch_queue(celery_app) # Celery on a Redis broker; queues=["emails", ...] to name them
377
+ platform.watch_queue(Queue("emails", connection=redis)) # RQ, in web servers and workers alike
378
+ ```
379
+
380
+ - **Celery** has no backlog API, so the broker's own lists are read from
381
+ Redis (one `LLEN` per queue and priority). Celery can't say what is
382
+ running, delayed or failed, nor how long a message has waited, so those
383
+ are left out; each process counts the tasks it published and, in a
384
+ worker, the ones it finished (a retry isn't finished). With a RabbitMQ
385
+ broker nothing is sent from here: the server agent reads RabbitMQ's own
386
+ numbers.
387
+ - **RQ**: the queue, its started, scheduled, deferred and failed job
388
+ registries, and when its first job was added. RQ has no hook for a job
389
+ being added; jobs finished are counted by `platform.rq_worker()`'s
390
+ worker, so watch the queue in the worker's code too.
391
+ - Watching one queue in several processes is fine: the dashboard takes the
392
+ largest reading, never the sum. Its Redis is named by host, port and
393
+ database; never a password.
394
+ - A reading that fails is a gap, not a zero. Readings run on a background
395
+ thread, never in a request.
396
+ - They ride on Server speed's switch; `queues=False` leaves them out
397
+ (protocol: `sdk/protocols/queues.md`).
398
+
399
+ ## Server analytics
400
+
401
+ For what only your server knows for sure. Not pageviews: the tracker counts those.
402
+
403
+ ```python
404
+ session = platform.session_id(request) # the visit, from the X-Analytics-Session header
405
+
406
+ platform.track("purchase", {"numeric_value": "49.90", "currency": "EUR", "event_uid": f"order-{order.id}"}, session_id=session)
407
+ platform.identify(user.id, session_id=session)
408
+ platform.lead({"email": "ana@example.com", "name": "Ana", "status": "qualified"})
409
+ ```
410
+
411
+ `track()` sends in the background; `identify()` and `lead()` wait for the
412
+ answer (up to 5 seconds). What you pass is taken as JSON when you pass it
413
+ (also `audit()`'s `details`): a date becomes ISO text, a `Decimal` a number,
414
+ a set a list, `NaN` null and anything else its `str()`, so one odd value can't
415
+ fail the batch it is sent in.
416
+
417
+ **Link to the visit:** send the tracker's session ID with your site's requests:
418
+
419
+ ```js
420
+ fetch("/api/orders", { headers: { "x-analytics-session": window.analyticsSession?.getId() || "" }, ... });
421
+ ```
422
+
423
+ ## Ad blockers
424
+
425
+ Many visitors use an ad blocker, and most blockers stop analytics. They
426
+ recognise **the address a script comes from** and **what it is called**, so a
427
+ tracker loaded from us is simply never counted.
428
+
429
+ This package already runs on your own domain, so it serves the tracker and
430
+ takes the visits itself. Nothing to set up: use the two addresses it gives you
431
+ in your page's template.
432
+
433
+ ```python
434
+ platform = get_platform() # Django; elsewhere your own platform
435
+ context = {
436
+ "script_url": platform.proxy.script_url(), # /3f9a2c1b/js/v1/s.js
437
+ "collect_url": platform.proxy.collect_url(), # /3f9a2c1b/api/e
438
+ }
439
+ ```
440
+
441
+ ```html
442
+ <script async src="{{ script_url }}" data-siteid="..." data-domain="example.com"
443
+ data-collect="{{ collect_url }}"></script>
444
+ ```
445
+
446
+ - The path comes from your secret, so it isn't in the page and isn't a name
447
+ any blocker lists. Pick your own with `proxy={"prefix": "..."}`.
448
+ - Tracker files are fetched once and then held in memory; if we can't be
449
+ reached, the copy already held is served rather than nothing.
450
+ - The visitor's address goes on as `X-Forwarded-For` (your collector needs it
451
+ for the country, the daily visitor ID and the rate limit) and cookies never
452
+ leave your server.
453
+ - Beacons are answered before the firewall, the crawler count and the timing,
454
+ so your own analytics traffic doesn't appear as pages of your site. On ASGI
455
+ the send runs in a thread, so the event loop keeps turning.
456
+ - Works through the Django, WSGI (Flask, Bottle, Pyramid) and ASGI (FastAPI,
457
+ Starlette) middleware alike.
458
+ - A denied or banned address (and one in the blocklists or shared blocks)
459
+ gets nothing from it either; beacons are never rate-limited or checked.
460
+ - It runs on your request threads, so it never waits long: 2 seconds at most,
461
+ and after 3 failures in a row the collector is left alone for 30 seconds
462
+ (beacons are answered `502` at once, which the tracker treats as lost, and a
463
+ tracker file that couldn't be fetched isn't asked for again for 30 seconds).
464
+ - Switch it off with `proxy=False`, or in the dashboard (Site settings →
465
+ Server). Then load the tracker from us as before.
466
+
467
+ ## Shutting down and serverless
468
+
469
+ `platform.flush()` sends everything waiting (on Lambda, before returning).
470
+ `platform.close()` also stops the background threads.
471
+
472
+ When the process ends (and when a Celery, RQ or Dramatiq worker shuts down),
473
+ what's waiting is sent within **5 seconds** for every module together, so the
474
+ package never outlasts a container's or PM2's 30-second grace period: no retry
475
+ sleeps then, and the errors that weren't sent wait in the errors file for the
476
+ next start. `platform.flush_before_exit()` does the same from your own
477
+ shutdown hook. The errors file a resend takes is deleted only once it has
478
+ been sent, so a process killed meanwhile loses nothing (the next start takes
479
+ over a file left behind for 5 minutes).