analytics-platform 0.3.4__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 (50) hide show
  1. {analytics_platform-0.3.4 → analytics_platform-0.3.5}/PKG-INFO +91 -6
  2. {analytics_platform-0.3.4 → analytics_platform-0.3.5}/README.md +90 -5
  3. {analytics_platform-0.3.4 → analytics_platform-0.3.5}/analytics_platform/__init__.py +189 -14
  4. analytics_platform-0.3.5/analytics_platform/calls.py +253 -0
  5. analytics_platform-0.3.5/analytics_platform/celery.py +199 -0
  6. {analytics_platform-0.3.4 → analytics_platform-0.3.5}/analytics_platform/core.py +1 -1
  7. {analytics_platform-0.3.4 → analytics_platform-0.3.5}/analytics_platform/django.py +46 -2
  8. {analytics_platform-0.3.4 → analytics_platform-0.3.5}/analytics_platform/firewall.py +230 -23
  9. {analytics_platform-0.3.4 → analytics_platform-0.3.5}/analytics_platform/performance.py +54 -7
  10. {analytics_platform-0.3.4 → analytics_platform-0.3.5}/analytics_platform/queries.py +396 -70
  11. analytics_platform-0.3.5/analytics_platform/queues.py +188 -0
  12. {analytics_platform-0.3.4 → analytics_platform-0.3.5}/analytics_platform/request_rules.py +102 -25
  13. {analytics_platform-0.3.4 → analytics_platform-0.3.5}/analytics_platform/resources.py +308 -1
  14. {analytics_platform-0.3.4 → analytics_platform-0.3.5}/analytics_platform/rq.py +96 -0
  15. {analytics_platform-0.3.4 → analytics_platform-0.3.5}/analytics_platform.egg-info/PKG-INFO +91 -6
  16. {analytics_platform-0.3.4 → analytics_platform-0.3.5}/analytics_platform.egg-info/SOURCES.txt +7 -1
  17. {analytics_platform-0.3.4 → analytics_platform-0.3.5}/pyproject.toml +1 -1
  18. analytics_platform-0.3.5/tests/test_calls_queues.py +539 -0
  19. {analytics_platform-0.3.4 → analytics_platform-0.3.5}/tests/test_hardening.py +23 -0
  20. analytics_platform-0.3.5/tests/test_limits.py +154 -0
  21. analytics_platform-0.3.5/tests/test_page_limits.py +187 -0
  22. {analytics_platform-0.3.4 → analytics_platform-0.3.5}/tests/test_queries.py +2 -1
  23. analytics_platform-0.3.5/tests/test_static_late.py +285 -0
  24. analytics_platform-0.3.4/analytics_platform/celery.py +0 -91
  25. {analytics_platform-0.3.4 → analytics_platform-0.3.5}/LICENSE +0 -0
  26. {analytics_platform-0.3.4 → analytics_platform-0.3.5}/analytics_platform/analytics.py +0 -0
  27. {analytics_platform-0.3.4 → analytics_platform-0.3.5}/analytics_platform/audit.py +0 -0
  28. {analytics_platform-0.3.4 → analytics_platform-0.3.5}/analytics_platform/challenge.py +0 -0
  29. {analytics_platform-0.3.4 → analytics_platform-0.3.5}/analytics_platform/crawlers.py +0 -0
  30. {analytics_platform-0.3.4 → analytics_platform-0.3.5}/analytics_platform/dramatiq.py +0 -0
  31. {analytics_platform-0.3.4 → analytics_platform-0.3.5}/analytics_platform/errors.py +0 -0
  32. {analytics_platform-0.3.4 → analytics_platform-0.3.5}/analytics_platform/flags.py +0 -0
  33. {analytics_platform-0.3.4 → analytics_platform-0.3.5}/analytics_platform/forms.py +0 -0
  34. {analytics_platform-0.3.4 → analytics_platform-0.3.5}/analytics_platform/inventory.py +0 -0
  35. {analytics_platform-0.3.4 → analytics_platform-0.3.5}/analytics_platform/maintenance.py +0 -0
  36. {analytics_platform-0.3.4 → analytics_platform-0.3.5}/analytics_platform/posture.py +0 -0
  37. {analytics_platform-0.3.4 → analytics_platform-0.3.5}/analytics_platform/proxy.py +0 -0
  38. {analytics_platform-0.3.4 → analytics_platform-0.3.5}/analytics_platform.egg-info/dependency_links.txt +0 -0
  39. {analytics_platform-0.3.4 → analytics_platform-0.3.5}/analytics_platform.egg-info/top_level.txt +0 -0
  40. {analytics_platform-0.3.4 → analytics_platform-0.3.5}/setup.cfg +0 -0
  41. {analytics_platform-0.3.4 → analytics_platform-0.3.5}/tests/test_celery.py +0 -0
  42. {analytics_platform-0.3.4 → analytics_platform-0.3.5}/tests/test_challenge.py +0 -0
  43. {analytics_platform-0.3.4 → analytics_platform-0.3.5}/tests/test_checkin.py +0 -0
  44. {analytics_platform-0.3.4 → analytics_platform-0.3.5}/tests/test_crawlers.py +0 -0
  45. {analytics_platform-0.3.4 → analytics_platform-0.3.5}/tests/test_errors.py +0 -0
  46. {analytics_platform-0.3.4 → analytics_platform-0.3.5}/tests/test_firewall.py +0 -0
  47. {analytics_platform-0.3.4 → analytics_platform-0.3.5}/tests/test_proxy.py +0 -0
  48. {analytics_platform-0.3.4 → analytics_platform-0.3.5}/tests/test_request_rules.py +0 -0
  49. {analytics_platform-0.3.4 → analytics_platform-0.3.5}/tests/test_resources.py +0 -0
  50. {analytics_platform-0.3.4 → analytics_platform-0.3.5}/tests/test_rq_dramatiq.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: analytics-platform
3
- Version: 0.3.4
3
+ Version: 0.3.5
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
@@ -106,6 +106,7 @@ Celery's `task_always_eager`.
106
106
  process of its own, so it sends after every job; only the worker checks
107
107
  in (Health → All systems), not a process per job.
108
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).
109
110
 
110
111
  ## Modules
111
112
 
@@ -121,8 +122,8 @@ Celery's `task_always_eager`.
121
122
  | `maintenance` | A "back soon" page while maintenance mode is on in the dashboard | on |
122
123
  | `headers` | Adds the security headers you pick on the Security page; your own headers win | on |
123
124
  | `forms` | Checks forms before you accept them: `platform.check_form(request)` | on |
124
- | `performance` | Response times and errors of every route (Health → Server speed): Django, Flask and FastAPI route patterns, and the database queries each one ran (below) | on |
125
- | `crawlers` | Which search engines and AI bots fetch your pages (Health → Crawlers). Django counts them for you; elsewhere call `platform.record_crawler(...)` | 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 |
126
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 |
127
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 |
128
129
 
@@ -275,15 +276,26 @@ Several services on one site: `Platform(..., server_name="worker")`.
275
276
  **Several apps on one site** (microservices, a worker, a Node service next to
276
277
  this one): install the package in each, with the same `site_id`, `secret` and
277
278
  `endpoint`, and a `server_name` of its own. Errors, Server speed, Crawlers and
278
- Security → Updates then tell the apps apart, and Health → All systems lists
279
+ Health → Updates then tell the apps apart, and Health → All systems lists
279
280
  each with a light: the package checks in about once a minute, so an app that
280
281
  stops reads "Stopped" within ten minutes (`sdk/protocols/checkin.md`).
281
282
  Without one, each app is named
282
283
  after its machine: two apps on one machine are reported as one, and only the
283
284
  last package list sent is kept. A service only your other apps call takes
284
285
  `firewall=False, proxy=False, maintenance=False` (its requests all come from
285
- your own apps, so the rate limit would see one visitor); on a machine shared
286
- by several apps, keep `resources` on in only one of them.
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").
287
299
 
288
300
  Options: `server_name`, `root` (traceback paths relative to it),
289
301
  `capture_uncaught`, `capture_logging`, `spool_dir` (where unsent errors wait;
@@ -304,6 +316,7 @@ read, so no data leaves it.
304
316
  | SQLAlchemy | nothing: every engine (Flask-SQLAlchemy too) |
305
317
  | pymongo | nothing, but **create the platform before your `MongoClient`**: pymongo only reports to clients made after the package is listening |
306
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 |
307
320
 
308
321
  A query outside any request or job is counted as `JOB (background)`. Each
309
322
  query is counted once: when SQLAlchemy or Django runs it over a watched
@@ -311,6 +324,78 @@ connection, they count it and the connection doesn't. It is on with
311
324
  `performance`, and switched off with it in the dashboard; `queries=False`
312
325
  keeps Server speed and leaves queries out (protocol: `sdk/protocols/queries.md`).
313
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
+
314
399
  ## Server analytics
315
400
 
316
401
  For what only your server knows for sure. Not pageviews: the tracker counts those.
@@ -80,6 +80,7 @@ Celery's `task_always_eager`.
80
80
  process of its own, so it sends after every job; only the worker checks
81
81
  in (Health → All systems), not a process per job.
82
82
  - Don't also wrap a task in `platform.job()`: it would be timed twice.
83
+ - Each queue's backlog: [`platform.watch_queue(...)`](#job-queues).
83
84
 
84
85
  ## Modules
85
86
 
@@ -95,8 +96,8 @@ Celery's `task_always_eager`.
95
96
  | `maintenance` | A "back soon" page while maintenance mode is on in the dashboard | on |
96
97
  | `headers` | Adds the security headers you pick on the Security page; your own headers win | on |
97
98
  | `forms` | Checks forms before you accept them: `platform.check_form(request)` | on |
98
- | `performance` | Response times and errors of every route (Health → Server speed): Django, Flask and FastAPI route patterns, and the database queries each one ran (below) | on |
99
- | `crawlers` | Which search engines and AI bots fetch your pages (Health → Crawlers). Django counts them for you; elsewhere call `platform.record_crawler(...)` | on |
99
+ | `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 |
100
+ | `crawlers` | Which search engines and AI bots fetch your pages (Off-site → Crawlers). Django counts them for you; elsewhere call `platform.record_crawler(...)` | on |
100
101
  | `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 |
101
102
  | `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 |
102
103
 
@@ -249,15 +250,26 @@ Several services on one site: `Platform(..., server_name="worker")`.
249
250
  **Several apps on one site** (microservices, a worker, a Node service next to
250
251
  this one): install the package in each, with the same `site_id`, `secret` and
251
252
  `endpoint`, and a `server_name` of its own. Errors, Server speed, Crawlers and
252
- Security → Updates then tell the apps apart, and Health → All systems lists
253
+ Health → Updates then tell the apps apart, and Health → All systems lists
253
254
  each with a light: the package checks in about once a minute, so an app that
254
255
  stops reads "Stopped" within ten minutes (`sdk/protocols/checkin.md`).
255
256
  Without one, each app is named
256
257
  after its machine: two apps on one machine are reported as one, and only the
257
258
  last package list sent is kept. A service only your other apps call takes
258
259
  `firewall=False, proxy=False, maintenance=False` (its requests all come from
259
- your own apps, so the rate limit would see one visitor); on a machine shared
260
- by several apps, keep `resources` on in only one of them.
260
+ your own apps, so the rate limit would see one visitor).
261
+
262
+ **Several apps on one machine**: leave `resources` on in all of them. The
263
+ machine's CPU, memory and disks are shown once, and each app (each gunicorn
264
+ or Celery worker process too) adds what is its own: its container's memory
265
+ and CPU caps and how close it is to them, how often the cap throttled it,
266
+ processes the kernel killed for memory, open files against the limit, the
267
+ most requests in progress at once, and how long it has run. On Linux the
268
+ machine's waiting time (`/proc/pressure`), connections refused before the
269
+ app accepted them and the connection-tracking table come with them. Python
270
+ sends no heap or garbage-collection figures. A figure the machine won't give
271
+ (no cap, no PSI, not Linux) is left out, never sent as 0 (protocol:
272
+ `sdk/protocols/resources.md`, "Limits").
261
273
 
262
274
  Options: `server_name`, `root` (traceback paths relative to it),
263
275
  `capture_uncaught`, `capture_logging`, `spool_dir` (where unsent errors wait;
@@ -278,6 +290,7 @@ read, so no data leaves it.
278
290
  | SQLAlchemy | nothing: every engine (Flask-SQLAlchemy too) |
279
291
  | pymongo | nothing, but **create the platform before your `MongoClient`**: pymongo only reports to clients made after the package is listening |
280
292
  | Any other DB-API driver (psycopg, psycopg2, mysqlclient, PyMySQL, sqlite3...) | `conn = platform.watch_database(psycopg.connect(...))`, and use `conn` as before |
293
+ | ClickHouse: clickhouse-connect, clickhouse-driver | `client = platform.watch_database(clickhouse_connect.get_client(...))` (or a `clickhouse_driver.Client`), and use `client` as before |
281
294
 
282
295
  A query outside any request or job is counted as `JOB (background)`. Each
283
296
  query is counted once: when SQLAlchemy or Django runs it over a watched
@@ -285,6 +298,78 @@ connection, they count it and the connection doesn't. It is on with
285
298
  `performance`, and switched off with it in the dashboard; `queries=False`
286
299
  keeps Server speed and leaves queries out (protocol: `sdk/protocols/queries.md`).
287
300
 
301
+ ClickHouse: clickhouse-connect's `query()` (and `query_df()`, `query_np()`,
302
+ `query_arrow()`, `raw_query()`) reads the whole result before it returns, so
303
+ its time is the result's too; `command()` and the inserts are timed until
304
+ they answer. clickhouse-driver's `execute()` likewise. The streaming
305
+ queries (`query_rows_stream()`, `execute_iter()`) aren't timed. Only the
306
+ client you pass is wrapped; the library itself is left alone.
307
+
308
+ ## Outgoing calls
309
+
310
+ Health → Server speed → Calls shows how long each route waited on other
311
+ services (a payment provider, an email API, your own microservices) by the
312
+ host it called (`api.stripe.com`), with how many calls failed. One line for
313
+ each client, where you make it:
314
+
315
+ ```python
316
+ session = platform.watch_http(requests.Session()) # requests
317
+ client = platform.watch_http(httpx.Client()) # httpx; AsyncClient too
318
+
319
+ # httpx, with calls that got no answer counted too:
320
+ client = httpx.Client(transport=platform.watch_http(httpx.HTTPTransport()))
321
+ ```
322
+
323
+ - **Only the host is sent.** A call's path, query string, headers and body
324
+ never leave your server: paths carry IDs and keys.
325
+ - A call is timed from being sent to its response's headers arriving. A
326
+ call that got no answer (refused, timed out), or a 5xx, is a failed one; a
327
+ 4xx is the other side's answer, not a failure. A call your code cancels
328
+ isn't counted.
329
+ - requests: each adapter the session has mounted is wrapped, keeping its
330
+ retries and pool settings; mount your own adapters before watching.
331
+ - httpx: `watch_http(client)` adds event hooks, which see every answered
332
+ call but not one that got no answer at all. A watched transport
333
+ (`AsyncHTTPTransport` for an `AsyncClient`) sees those too; with both on
334
+ one client a call is counted once.
335
+ - urllib3 and `urllib.request` have no public hook, so their calls aren't
336
+ counted (and nothing of theirs is patched).
337
+ - Calls are filed as queries are: under the request, the job, or
338
+ `JOB (background)`. The package's own calls to us are left out.
339
+ - They ride on Server speed's switch; `calls=False` leaves them out
340
+ (protocol: `sdk/protocols/calls.md`).
341
+
342
+ ## Job queues
343
+
344
+ Health → Server speed → Jobs shows each queue's backlog once a minute: jobs
345
+ waiting, running, delayed and failed, how long the oldest has waited, and
346
+ jobs added against jobs finished, so a queue falling behind shows before the
347
+ emails go out an hour late.
348
+
349
+ ```python
350
+ platform.watch_queue(celery_app) # Celery on a Redis broker; queues=["emails", ...] to name them
351
+ platform.watch_queue(Queue("emails", connection=redis)) # RQ, in web servers and workers alike
352
+ ```
353
+
354
+ - **Celery** has no backlog API, so the broker's own lists are read from
355
+ Redis (one `LLEN` per queue and priority). Celery can't say what is
356
+ running, delayed or failed, nor how long a message has waited, so those
357
+ are left out; each process counts the tasks it published and, in a
358
+ worker, the ones it finished (a retry isn't finished). With a RabbitMQ
359
+ broker nothing is sent from here: the server agent reads RabbitMQ's own
360
+ numbers.
361
+ - **RQ**: the queue, its started, scheduled, deferred and failed job
362
+ registries, and when its first job was added. RQ has no hook for a job
363
+ being added; jobs finished are counted by `platform.rq_worker()`'s
364
+ worker, so watch the queue in the worker's code too.
365
+ - Watching one queue in several processes is fine: the dashboard takes the
366
+ largest reading, never the sum. Its Redis is named by host, port and
367
+ database; never a password.
368
+ - A reading that fails is a gap, not a zero. Readings run on a background
369
+ thread, never in a request.
370
+ - They ride on Server speed's switch; `queues=False` leaves them out
371
+ (protocol: `sdk/protocols/queues.md`).
372
+
288
373
  ## Server analytics
289
374
 
290
375
  For what only your server knows for sure. Not pageviews: the tracker counts those.
@@ -82,13 +82,15 @@ class Platform:
82
82
  "cloudflare"); debug (print send problems); per module True / False / {options}.
83
83
  client: the name this package goes by to the collector ("python", or
84
84
  "django", which the Django integration passes), so a request rule aimed
85
- at one framework reaches it. queries=False keeps Server speed and leaves
86
- database queries out of it."""
85
+ at one framework reaches it. queries=False, calls=False and queues=False
86
+ keep Server speed and leave database queries, outgoing calls or job
87
+ queues out of it."""
87
88
 
88
89
  def __init__(self, site_id, secret, endpoint, trust_proxy=None, debug=False, firewall=True, errors=True, analytics=True,
89
90
  performance=True, crawlers=True, forms=True, headers=True, audit=True, flags=True, maintenance=True,
90
91
  inventory=True,
91
- posture=True, resources=True, proxy=True, server_name=None, client="python", queries=True):
92
+ posture=True, resources=True, proxy=True, server_name=None, client="python", queries=True,
93
+ calls=True, queues=True):
92
94
  self.core = Core(site_id, secret, endpoint, debug, server_name=server_name, client=client)
93
95
  if not self.core.valid:
94
96
  print("[analytics-platform] site_id, secret and endpoint are required; nothing is sent and every request passes")
@@ -110,6 +112,8 @@ class Platform:
110
112
  opts.setdefault("server_name", server_name)
111
113
  if name == "performance":
112
114
  opts.setdefault("queries", queries)
115
+ opts.setdefault("calls", calls)
116
+ opts.setdefault("queues", queues)
113
117
  # Inventory and posture call it host, and are stored one per site
114
118
  # and host: without it two services on one machine overwrite each other's.
115
119
  if server_name and name in ("inventory", "posture"):
@@ -118,6 +122,9 @@ class Platform:
118
122
  self._modules[name] = _load(name, self.core, opts)
119
123
  except Exception as error:
120
124
  print("[analytics-platform] the %s module could not start and is off: %s" % (name, error))
125
+ # Requests in progress at once (the limits pass), from the request timing.
126
+ if "performance" in self._modules and "resources" in self._modules:
127
+ self._modules["resources"].inflight_peak = self._modules["performance"].take_inflight_peak
121
128
  self.core.start(list(self._modules))
122
129
  # At exit, what's waiting is sent within one deadline for every
123
130
  # module together (EXIT_DEADLINE): registered after the core's own
@@ -332,8 +339,9 @@ class Platform:
332
339
 
333
340
  def watch_database(self, connection, db=None):
334
341
  """A DB-API connection (psycopg, psycopg2, mysqlclient, PyMySQL,
335
- sqlite3...) whose queries are timed for Server speed: use what it
336
- returns in its place. db: "postgresql", "mysql"... when the driver
342
+ sqlite3...) or a ClickHouse client (clickhouse-connect,
343
+ clickhouse-driver) whose queries are timed for Server speed: use what
344
+ it returns in its place. db: "postgresql", "mysql"... when the driver
337
345
  doesn't say. Django, SQLAlchemy and pymongo need nothing of this."""
338
346
  queries = self._queries()
339
347
  if queries is None:
@@ -341,13 +349,43 @@ class Platform:
341
349
  from .queries import watch_database
342
350
  return watch_database(connection, db)
343
351
 
352
+ def watch_http(self, client):
353
+ """A requests Session, an httpx Client or AsyncClient, or an httpx
354
+ transport whose outgoing calls are timed for Server speed (by host
355
+ only): use what it returns. See analytics_platform.calls."""
356
+ if self._calls() is None:
357
+ return client
358
+ try:
359
+ from .calls import watch_http
360
+ return watch_http(client)
361
+ except Exception as error:
362
+ self.core.log("performance", "watch_http: its calls aren't timed:", error)
363
+ return client
364
+
365
+ def watch_queue(self, queue_or_app, queues=None):
366
+ """A Celery app (Redis broker; queues: the names to read, default
367
+ its configured ones) or an RQ Queue, read once a minute for Server
368
+ speed -> Jobs. Returns what it was given."""
369
+ watcher = self._queues_module()
370
+ return watcher.watch(queue_or_app, queues) if watcher is not None else queue_or_app
371
+
344
372
  def _queries(self):
345
373
  performance = self._modules.get("performance")
346
- return performance.queries if performance else None
374
+ queries = performance.queries if performance else None
375
+ return queries if queries is not None and queries.has("queries") else None
376
+
377
+ def _calls(self):
378
+ performance = self._modules.get("performance")
379
+ queries = performance.queries if performance else None
380
+ return queries if queries is not None and queries.has("calls") else None
381
+
382
+ def _queues_module(self):
383
+ performance = self._modules.get("performance")
384
+ return performance.queues if performance else None
347
385
 
348
386
  def record_crawler(self, user_agent, path, status, started=None, size=0, ip=""):
349
387
  """Records a finished request when it came from a crawler or AI bot
350
- (Health → Crawlers). started: time.perf_counter() when it arrived."""
388
+ (Off-site → Crawlers). started: time.perf_counter() when it arrived."""
351
389
  crawlers = self._modules.get("crawlers")
352
390
  if crawlers:
353
391
  try:
@@ -389,10 +427,12 @@ class Platform:
389
427
 
390
428
  # ---- Requests ----
391
429
 
392
- def blocked(self, remote, headers, method, path):
430
+ def blocked(self, remote, headers, method, path, late=None):
393
431
  """(status, body, headers) when the firewall turns the request away,
394
432
  else None. headers: a dict with lowercase names. path: the request
395
- target as sent, query string and all, not yet decoded (request_target)."""
433
+ target as sent, query string and all, not yet decoded (request_target).
434
+ late: a dict given "static" when the request is a static file let
435
+ through, for count_static() after the response."""
396
436
  firewall = self._modules.get("firewall")
397
437
  if not firewall:
398
438
  return None
@@ -403,6 +443,8 @@ class Platform:
403
443
  if decision["action"] == "block" and decision["rule"] == "challenge":
404
444
  status, extra, body = firewall.challenge_page(ip, method)
405
445
  return status, body, extra
446
+ if late is not None and decision.get("static") and decision["action"] != "block":
447
+ late["static"] = decision["static"]
406
448
  except Exception as error:
407
449
  self.core.log("firewall", "check failed, request passed:", error)
408
450
  return None
@@ -410,10 +452,24 @@ class Platform:
410
452
  return None
411
453
  extra = [("Content-Type", "text/plain; charset=utf-8"), ("Cache-Control", "no-store")]
412
454
  if decision["status"] == 429:
413
- extra.append(("Retry-After", firewall.retry_after()))
414
- return 429, b"Too Many Requests", extra
455
+ from .firewall import LIMIT_BODY
456
+ extra.append(("Retry-After", firewall.retry_after(decision["rule"])))
457
+ return 429, LIMIT_BODY if decision["rule"].startswith("limit:") else b"Too Many Requests", extra
415
458
  return 403, b"Forbidden", extra
416
459
 
460
+ def count_static(self, late, status, routed=False):
461
+ """After the response: a static-looking request blocked() let through
462
+ (late["static"]) counts toward the rate and the page limits after all
463
+ when it answered 400 or more or the router sent it to a page
464
+ (firewall.md, `rate`). Never raises."""
465
+ firewall = self._modules.get("firewall")
466
+ if not firewall or not late or not late.get("static"):
467
+ return
468
+ try:
469
+ firewall.count_static(late["static"], status, routed)
470
+ except Exception as error:
471
+ self.core.log("firewall", "could not count a static file:", error)
472
+
417
473
  def wsgi(self, app, timing=True):
418
474
  """WSGI middleware (Flask, Bottle, Pyramid, plain WSGI). timing=False
419
475
  leaves timing to the framework integration (Flask, which knows the route)."""
@@ -432,8 +488,9 @@ class Platform:
432
488
  status, extra, body = answered
433
489
  start_response("%d " % status, list(extra))
434
490
  return [body]
491
+ late = {}
435
492
  blocked = platform.blocked(environ.get("REMOTE_ADDR", ""), headers, environ.get("REQUEST_METHOD", ""),
436
- _wsgi_target(environ))
493
+ _wsgi_target(environ), late)
437
494
  if blocked:
438
495
  status, body, extra = blocked
439
496
  start_response("%d %s" % (status, _reason(status)), extra)
@@ -451,14 +508,31 @@ class Platform:
451
508
  seen["status"] = int(str(status).split(" ", 1)[0])
452
509
  except ValueError:
453
510
  pass
511
+ seen["started"] = True
512
+ if late:
513
+ seen["type"] = next((v for n, v in response_headers if str(n).lower() == "content-type"), "")
454
514
  response_headers = list(response_headers) + platform.security_headers(n for n, _ in response_headers) + remember
455
515
  return start_response(status, response_headers, exc_info) if exc_info else start_response(status, response_headers)
456
516
 
457
517
  trail = current_trail.set([])
458
518
  queries = _queries_start(platform)
519
+ inflight = _inflight_start(platform)
459
520
  try:
460
- return app(environ, timed_start_response)
521
+ result = app(environ, timed_start_response)
522
+ # A static-looking request counts once its body is sent (the
523
+ # server closes the iterable): toward the rate and the page
524
+ # limits when it answered 400 or more, or Flask routed it to a
525
+ # view (init_flask leaves the word in the environ). Plain WSGI
526
+ # has no router to ask, so there only an error counts.
527
+ if not late or _nothing_to_count(environ, seen):
528
+ # A file a page asked for, answered already: the server's
529
+ # own file wrapper (sendfile) is left as it is.
530
+ return result
531
+ return _Counted(result, lambda: platform.count_static(
532
+ late, seen["status"], _wsgi_routed(environ, seen.get("type", ""))))
461
533
  except Exception as error:
534
+ if late:
535
+ platform.count_static(late, 500)
462
536
  platform.capture_exception(error, status=500, kind="error", request={
463
537
  "method": environ.get("REQUEST_METHOD", ""), "path": environ.get("PATH_INFO", ""),
464
538
  "session_id": headers.get(SESSION_HEADER, ""),
@@ -466,6 +540,7 @@ class Platform:
466
540
  raise
467
541
  finally:
468
542
  current_trail.reset(trail)
543
+ _inflight_end(inflight)
469
544
  try:
470
545
  if timing:
471
546
  platform.time_request(environ.get("REQUEST_METHOD", ""), "", seen["status"], started, environ.get("PATH_INFO", ""))
@@ -505,6 +580,15 @@ class Platform:
505
580
 
506
581
  @app.after_request
507
582
  def _analytics_timed(response):
583
+ # The rule the request matched, for a static-looking request's
584
+ # count after the response (wsgi() above): Flask's own static
585
+ # endpoint (and a blueprint's) serves files.
586
+ try:
587
+ rule = getattr(request, "url_rule", None)
588
+ if rule is not None and rule.endpoint != "static" and not str(rule.endpoint).endswith(".static"):
589
+ request.environ[_ROUTE_KEY] = rule.rule
590
+ except Exception:
591
+ pass
508
592
  started = getattr(g, "_analytics_started", None)
509
593
  if started is not None:
510
594
  rule = getattr(request, "url_rule", None)
@@ -540,6 +624,20 @@ def _queries_start(platform):
540
624
  return current_queries.set(queries.request()) if queries else None
541
625
 
542
626
 
627
+ def _inflight_start(platform):
628
+ """A request starts (not a job): counted as in progress until it ends."""
629
+ performance = (getattr(platform, "_modules", None) or {}).get("performance")
630
+ if performance is None:
631
+ return None
632
+ performance.request_started()
633
+ return performance
634
+
635
+
636
+ def _inflight_end(performance):
637
+ if performance is not None:
638
+ performance.request_ended()
639
+
640
+
543
641
  def _queries_end(token):
544
642
  """The request or job has finished: queries after this are another's."""
545
643
  if token is not None:
@@ -650,6 +748,65 @@ class _Job:
650
748
  return run
651
749
 
652
750
 
751
+ _ROUTE_KEY = "analytics_platform.route"
752
+
753
+
754
+ def _wsgi_routed(environ, content_type):
755
+ """Whether the router sent a WSGI request to a page: the route a
756
+ framework integration left in the environ (init_flask), else none."""
757
+ from .firewall import routed_to_page
758
+ return routed_to_page(environ.get(_ROUTE_KEY), content_type)
759
+
760
+
761
+ def _nothing_to_count(environ, seen):
762
+ """A WSGI response already started (its status known) that won't count
763
+ toward the rate and the page limits: under 400, and not routed to a page
764
+ (or a 304)."""
765
+ try:
766
+ if not seen.get("started"):
767
+ return False
768
+ status = seen["status"]
769
+ return status < 400 and (status == 304 or not _wsgi_routed(environ, seen.get("type", "")))
770
+ except Exception:
771
+ return False
772
+
773
+
774
+ class _Counted:
775
+ """A WSGI response that runs `done` once the server has sent it (close),
776
+ passing the server's close() on. Only wraps a static-looking request."""
777
+
778
+ def __init__(self, result, done):
779
+ self._result = result
780
+ self._done = done
781
+
782
+ def __iter__(self):
783
+ return iter(self._result)
784
+
785
+ def close(self):
786
+ try:
787
+ close = getattr(self._result, "close", None)
788
+ if close is not None:
789
+ close()
790
+ finally:
791
+ done, self._done = self._done, None
792
+ if done:
793
+ done()
794
+
795
+
796
+ def _asgi_routed(scope, content_type):
797
+ """Whether Starlette's router (FastAPI's too) sent the request to a
798
+ route: scope["route"] (FastAPI) or the endpoint Starlette left in the
799
+ scope. A StaticFiles mount serves files."""
800
+ from .firewall import routed_to_page
801
+ route = scope.get("route")
802
+ endpoint = scope.get("endpoint")
803
+ app = getattr(route, "app", None) if route is not None else endpoint
804
+ if type(app).__name__ == "StaticFiles" or type(endpoint).__name__ == "StaticFiles":
805
+ return False
806
+ pattern = getattr(route, "path", None) if route is not None else ("*" if endpoint is not None else None)
807
+ return routed_to_page(pattern, content_type)
808
+
809
+
653
810
  class PlatformMiddleware:
654
811
  """ASGI middleware: app.add_middleware(PlatformMiddleware, platform=platform)."""
655
812
 
@@ -695,7 +852,8 @@ class PlatformMiddleware:
695
852
  return
696
853
  # raw_path is the path as sent; servers that leave it out get path encoded again.
697
854
  target = request_target(scope.get("path", ""), scope.get("query_string", b""), scope.get("raw_path"))
698
- blocked = self.platform.blocked(client[0] or "", headers, scope.get("method", ""), target)
855
+ late = {}
856
+ blocked = self.platform.blocked(client[0] or "", headers, scope.get("method", ""), target, late)
699
857
  if blocked:
700
858
  status, body, extra = blocked
701
859
  await send({"type": "http.response.start", "status": status,
@@ -716,6 +874,8 @@ class PlatformMiddleware:
716
874
  async def timed_send(message):
717
875
  if message.get("type") == "http.response.start":
718
876
  seen["status"] = int(message.get("status") or 500)
877
+ if late:
878
+ seen["type"] = next((v.decode("latin-1") for k, v in message.get("headers") or [] if k.lower() == b"content-type"), "")
719
879
  present = [k.decode("latin-1") for k, _ in message.get("headers") or []]
720
880
  extra = self.platform.security_headers(present)
721
881
  if extra or remember:
@@ -731,8 +891,14 @@ class PlatformMiddleware:
731
891
  })
732
892
  trail = current_trail.set([])
733
893
  queries = _queries_start(self.platform)
894
+ inflight = _inflight_start(self.platform)
734
895
  try:
735
896
  await self.app(scope, receive, timed_send)
897
+ except asyncio.CancelledError:
898
+ # The visitor went away (a page left while its files load): no
899
+ # answer, and not the app's error.
900
+ seen["gone"] = True
901
+ raise
736
902
  except Exception as error:
737
903
  self.platform.capture_exception(error, status=500, kind="error", request={
738
904
  "method": scope.get("method", ""), "path": scope.get("path", ""),
@@ -742,12 +908,21 @@ class PlatformMiddleware:
742
908
  finally:
743
909
  current_request.reset(token)
744
910
  current_trail.reset(trail)
911
+ _inflight_end(inflight)
745
912
  # The router sets scope["route"] (FastAPI, Starlette) on the way in.
746
913
  try:
747
914
  self.platform.time_request(scope.get("method", ""), getattr(scope.get("route"), "path", "") or "",
748
915
  seen["status"], started, scope.get("path", ""))
749
916
  finally:
750
917
  _queries_end(queries)
918
+ # A static-looking request, once its body is sent: counted when
919
+ # it answered 400 or more or the router sent it to a route.
920
+ if late and not (seen.get("gone") and "type" not in seen):
921
+ try:
922
+ routed = _asgi_routed(scope, seen.get("type", ""))
923
+ except Exception:
924
+ routed = False
925
+ self.platform.count_static(late, seen["status"], routed)
751
926
 
752
927
 
753
928
  def create_platform(**options):