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.
- {analytics_platform-0.3.4 → analytics_platform-0.3.5}/PKG-INFO +91 -6
- {analytics_platform-0.3.4 → analytics_platform-0.3.5}/README.md +90 -5
- {analytics_platform-0.3.4 → analytics_platform-0.3.5}/analytics_platform/__init__.py +189 -14
- analytics_platform-0.3.5/analytics_platform/calls.py +253 -0
- analytics_platform-0.3.5/analytics_platform/celery.py +199 -0
- {analytics_platform-0.3.4 → analytics_platform-0.3.5}/analytics_platform/core.py +1 -1
- {analytics_platform-0.3.4 → analytics_platform-0.3.5}/analytics_platform/django.py +46 -2
- {analytics_platform-0.3.4 → analytics_platform-0.3.5}/analytics_platform/firewall.py +230 -23
- {analytics_platform-0.3.4 → analytics_platform-0.3.5}/analytics_platform/performance.py +54 -7
- {analytics_platform-0.3.4 → analytics_platform-0.3.5}/analytics_platform/queries.py +396 -70
- analytics_platform-0.3.5/analytics_platform/queues.py +188 -0
- {analytics_platform-0.3.4 → analytics_platform-0.3.5}/analytics_platform/request_rules.py +102 -25
- {analytics_platform-0.3.4 → analytics_platform-0.3.5}/analytics_platform/resources.py +308 -1
- {analytics_platform-0.3.4 → analytics_platform-0.3.5}/analytics_platform/rq.py +96 -0
- {analytics_platform-0.3.4 → analytics_platform-0.3.5}/analytics_platform.egg-info/PKG-INFO +91 -6
- {analytics_platform-0.3.4 → analytics_platform-0.3.5}/analytics_platform.egg-info/SOURCES.txt +7 -1
- {analytics_platform-0.3.4 → analytics_platform-0.3.5}/pyproject.toml +1 -1
- analytics_platform-0.3.5/tests/test_calls_queues.py +539 -0
- {analytics_platform-0.3.4 → analytics_platform-0.3.5}/tests/test_hardening.py +23 -0
- analytics_platform-0.3.5/tests/test_limits.py +154 -0
- analytics_platform-0.3.5/tests/test_page_limits.py +187 -0
- {analytics_platform-0.3.4 → analytics_platform-0.3.5}/tests/test_queries.py +2 -1
- analytics_platform-0.3.5/tests/test_static_late.py +285 -0
- analytics_platform-0.3.4/analytics_platform/celery.py +0 -91
- {analytics_platform-0.3.4 → analytics_platform-0.3.5}/LICENSE +0 -0
- {analytics_platform-0.3.4 → analytics_platform-0.3.5}/analytics_platform/analytics.py +0 -0
- {analytics_platform-0.3.4 → analytics_platform-0.3.5}/analytics_platform/audit.py +0 -0
- {analytics_platform-0.3.4 → analytics_platform-0.3.5}/analytics_platform/challenge.py +0 -0
- {analytics_platform-0.3.4 → analytics_platform-0.3.5}/analytics_platform/crawlers.py +0 -0
- {analytics_platform-0.3.4 → analytics_platform-0.3.5}/analytics_platform/dramatiq.py +0 -0
- {analytics_platform-0.3.4 → analytics_platform-0.3.5}/analytics_platform/errors.py +0 -0
- {analytics_platform-0.3.4 → analytics_platform-0.3.5}/analytics_platform/flags.py +0 -0
- {analytics_platform-0.3.4 → analytics_platform-0.3.5}/analytics_platform/forms.py +0 -0
- {analytics_platform-0.3.4 → analytics_platform-0.3.5}/analytics_platform/inventory.py +0 -0
- {analytics_platform-0.3.4 → analytics_platform-0.3.5}/analytics_platform/maintenance.py +0 -0
- {analytics_platform-0.3.4 → analytics_platform-0.3.5}/analytics_platform/posture.py +0 -0
- {analytics_platform-0.3.4 → analytics_platform-0.3.5}/analytics_platform/proxy.py +0 -0
- {analytics_platform-0.3.4 → analytics_platform-0.3.5}/analytics_platform.egg-info/dependency_links.txt +0 -0
- {analytics_platform-0.3.4 → analytics_platform-0.3.5}/analytics_platform.egg-info/top_level.txt +0 -0
- {analytics_platform-0.3.4 → analytics_platform-0.3.5}/setup.cfg +0 -0
- {analytics_platform-0.3.4 → analytics_platform-0.3.5}/tests/test_celery.py +0 -0
- {analytics_platform-0.3.4 → analytics_platform-0.3.5}/tests/test_challenge.py +0 -0
- {analytics_platform-0.3.4 → analytics_platform-0.3.5}/tests/test_checkin.py +0 -0
- {analytics_platform-0.3.4 → analytics_platform-0.3.5}/tests/test_crawlers.py +0 -0
- {analytics_platform-0.3.4 → analytics_platform-0.3.5}/tests/test_errors.py +0 -0
- {analytics_platform-0.3.4 → analytics_platform-0.3.5}/tests/test_firewall.py +0 -0
- {analytics_platform-0.3.4 → analytics_platform-0.3.5}/tests/test_proxy.py +0 -0
- {analytics_platform-0.3.4 → analytics_platform-0.3.5}/tests/test_request_rules.py +0 -0
- {analytics_platform-0.3.4 → analytics_platform-0.3.5}/tests/test_resources.py +0 -0
- {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.
|
|
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,
|
|
125
|
-
| `crawlers` | Which search engines and AI bots fetch your pages (
|
|
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
|
-
|
|
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)
|
|
286
|
-
|
|
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,
|
|
99
|
-
| `crawlers` | Which search engines and AI bots fetch your pages (
|
|
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
|
-
|
|
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)
|
|
260
|
-
|
|
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
|
|
86
|
-
database queries
|
|
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...)
|
|
336
|
-
|
|
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
|
-
|
|
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
|
-
(
|
|
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
|
-
|
|
414
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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):
|