analytics-platform 0.3.3__tar.gz → 0.3.4__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.3 → analytics_platform-0.3.4}/PKG-INFO +134 -7
- {analytics_platform-0.3.3 → analytics_platform-0.3.4}/README.md +132 -5
- {analytics_platform-0.3.3 → analytics_platform-0.3.4}/analytics_platform/__init__.py +193 -27
- {analytics_platform-0.3.3 → analytics_platform-0.3.4}/analytics_platform/analytics.py +8 -4
- {analytics_platform-0.3.3 → analytics_platform-0.3.4}/analytics_platform/audit.py +4 -2
- analytics_platform-0.3.4/analytics_platform/celery.py +91 -0
- analytics_platform-0.3.4/analytics_platform/challenge.py +167 -0
- {analytics_platform-0.3.3 → analytics_platform-0.3.4}/analytics_platform/core.py +211 -14
- {analytics_platform-0.3.3 → analytics_platform-0.3.4}/analytics_platform/crawlers.py +7 -0
- {analytics_platform-0.3.3 → analytics_platform-0.3.4}/analytics_platform/django.py +43 -12
- analytics_platform-0.3.4/analytics_platform/dramatiq.py +68 -0
- {analytics_platform-0.3.3 → analytics_platform-0.3.4}/analytics_platform/errors.py +173 -35
- analytics_platform-0.3.4/analytics_platform/firewall.py +535 -0
- {analytics_platform-0.3.3 → analytics_platform-0.3.4}/analytics_platform/maintenance.py +9 -11
- {analytics_platform-0.3.3 → analytics_platform-0.3.4}/analytics_platform/performance.py +38 -5
- {analytics_platform-0.3.3 → analytics_platform-0.3.4}/analytics_platform/posture.py +8 -8
- {analytics_platform-0.3.3 → analytics_platform-0.3.4}/analytics_platform/proxy.py +77 -8
- analytics_platform-0.3.4/analytics_platform/queries.py +738 -0
- {analytics_platform-0.3.3 → analytics_platform-0.3.4}/analytics_platform/request_rules.py +62 -6
- {analytics_platform-0.3.3 → analytics_platform-0.3.4}/analytics_platform/resources.py +1 -1
- analytics_platform-0.3.4/analytics_platform/rq.py +97 -0
- {analytics_platform-0.3.3 → analytics_platform-0.3.4}/analytics_platform.egg-info/PKG-INFO +134 -7
- {analytics_platform-0.3.3 → analytics_platform-0.3.4}/analytics_platform.egg-info/SOURCES.txt +11 -1
- {analytics_platform-0.3.3 → analytics_platform-0.3.4}/pyproject.toml +4 -1
- analytics_platform-0.3.4/tests/test_celery.py +206 -0
- analytics_platform-0.3.4/tests/test_challenge.py +407 -0
- analytics_platform-0.3.4/tests/test_checkin.py +87 -0
- analytics_platform-0.3.4/tests/test_firewall.py +96 -0
- analytics_platform-0.3.4/tests/test_hardening.py +512 -0
- analytics_platform-0.3.4/tests/test_queries.py +258 -0
- analytics_platform-0.3.4/tests/test_rq_dramatiq.py +302 -0
- analytics_platform-0.3.3/analytics_platform/firewall.py +0 -316
- analytics_platform-0.3.3/tests/test_checkin.py +0 -29
- analytics_platform-0.3.3/tests/test_firewall.py +0 -56
- {analytics_platform-0.3.3 → analytics_platform-0.3.4}/LICENSE +0 -0
- {analytics_platform-0.3.3 → analytics_platform-0.3.4}/analytics_platform/flags.py +0 -0
- {analytics_platform-0.3.3 → analytics_platform-0.3.4}/analytics_platform/forms.py +0 -0
- {analytics_platform-0.3.3 → analytics_platform-0.3.4}/analytics_platform/inventory.py +0 -0
- {analytics_platform-0.3.3 → analytics_platform-0.3.4}/analytics_platform.egg-info/dependency_links.txt +0 -0
- {analytics_platform-0.3.3 → analytics_platform-0.3.4}/analytics_platform.egg-info/top_level.txt +0 -0
- {analytics_platform-0.3.3 → analytics_platform-0.3.4}/setup.cfg +0 -0
- {analytics_platform-0.3.3 → analytics_platform-0.3.4}/tests/test_crawlers.py +0 -0
- {analytics_platform-0.3.3 → analytics_platform-0.3.4}/tests/test_errors.py +0 -0
- {analytics_platform-0.3.3 → analytics_platform-0.3.4}/tests/test_proxy.py +0 -0
- {analytics_platform-0.3.3 → analytics_platform-0.3.4}/tests/test_request_rules.py +0 -0
- {analytics_platform-0.3.3 → analytics_platform-0.3.4}/tests/test_resources.py +0 -0
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: analytics-platform
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.4
|
|
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
|
|
7
|
-
Keywords: firewall,waf,rate-limit,error-monitoring,analytics,server-monitoring,django,flask,fastapi,wsgi,asgi
|
|
7
|
+
Keywords: firewall,waf,rate-limit,error-monitoring,analytics,server-monitoring,django,flask,fastapi,celery,rq,dramatiq,wsgi,asgi
|
|
8
8
|
Classifier: Development Status :: 4 - Beta
|
|
9
9
|
Classifier: Intended Audience :: Developers
|
|
10
10
|
Classifier: Intended Audience :: System Administrators
|
|
@@ -53,7 +53,9 @@ ANALYTICS_PLATFORM = {
|
|
|
53
53
|
}
|
|
54
54
|
```
|
|
55
55
|
|
|
56
|
-
In your code: `from analytics_platform.django import get_platform`.
|
|
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.
|
|
57
59
|
|
|
58
60
|
## FastAPI / Starlette
|
|
59
61
|
|
|
@@ -75,6 +77,36 @@ platform.init_flask(app)
|
|
|
75
77
|
|
|
76
78
|
Other WSGI apps: `app = platform.wsgi(app)`; other ASGI apps: `app = platform.asgi(app)`.
|
|
77
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
|
+
|
|
78
110
|
## Modules
|
|
79
111
|
|
|
80
112
|
| Module | What it does | Default |
|
|
@@ -89,7 +121,7 @@ Other WSGI apps: `app = platform.wsgi(app)`; other ASGI apps: `app = platform.as
|
|
|
89
121
|
| `maintenance` | A "back soon" page while maintenance mode is on in the dashboard | on |
|
|
90
122
|
| `headers` | Adds the security headers you pick on the Security page; your own headers win | on |
|
|
91
123
|
| `forms` | Checks forms before you accept them: `platform.check_form(request)` | on |
|
|
92
|
-
| `performance` | Response times and errors of every route (Health → Server speed): Django, Flask and FastAPI route patterns | 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 |
|
|
93
125
|
| `crawlers` | Which search engines and AI bots fetch your pages (Health → Crawlers). Django counts them for you; elsewhere call `platform.record_crawler(...)` | on |
|
|
94
126
|
| `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 |
|
|
95
127
|
| `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 |
|
|
@@ -120,7 +152,44 @@ Options: `mode` (`"monitor"` or `"block"`, overrides the dashboard),
|
|
|
120
152
|
**The visitor's address:** behind a proxy, every request comes from the proxy
|
|
121
153
|
unless you set `trust_proxy`: `1` for one proxy or load balancer, `2` for
|
|
122
154
|
Cloudflare in front of a load balancer, `"cloudflare"` when every request comes
|
|
123
|
-
through Cloudflare
|
|
155
|
+
through Cloudflare (`CF-Connecting-IP` is believed only from Cloudflare's own
|
|
156
|
+
addresses: anyone who reaches your server directly can send it).
|
|
157
|
+
`X-Forwarded-For` is read from the right, as each proxy appends what it saw;
|
|
158
|
+
a port on an entry (`203.0.113.5:54321`, `[2001:db8::1]:443`, as Azure sends
|
|
159
|
+
them) is dropped, and an entry that isn't an address (`unknown`, a name)
|
|
160
|
+
means the connection's own address is used rather than no check at all.
|
|
161
|
+
|
|
162
|
+
When something in front of the package already works out the address, count
|
|
163
|
+
each proxy once:
|
|
164
|
+
|
|
165
|
+
- **gunicorn** never changes `REMOTE_ADDR` (`--forwarded-allow-ips` is about
|
|
166
|
+
`X-Forwarded-Proto` only): set `trust_proxy`.
|
|
167
|
+
- **Uvicorn** (`--proxy-headers`, on by default, trusting the addresses in
|
|
168
|
+
`--forwarded-allow-ips`) puts the visitor's address in place itself, and
|
|
169
|
+
leaves the header as it was. Leave `trust_proxy` unset when
|
|
170
|
+
`--forwarded-allow-ips` lists your proxies' addresses. With
|
|
171
|
+
`--forwarded-allow-ips='*'` (`FORWARDED_ALLOW_IPS=*`, common in Docker images)
|
|
172
|
+
Uvicorn takes the **leftmost** entry, which the visitor writes: there, set
|
|
173
|
+
`trust_proxy` to your number of proxies, and the package reads the header
|
|
174
|
+
itself from the right.
|
|
175
|
+
- **werkzeug's `ProxyFix`**: wrapped inside the package's middleware
|
|
176
|
+
(`app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1)` before `init_flask`), the
|
|
177
|
+
package uses its `x_for` when `trust_proxy` is unset; wrapped outside, it has
|
|
178
|
+
already set `REMOTE_ADDR` and unset `trust_proxy` uses that.
|
|
179
|
+
- **Apache's `mod_remoteip`** (`mod_wsgi`) sets `REMOTE_ADDR` to the visitor
|
|
180
|
+
and *removes the entries it used* from `X-Forwarded-For`, leaving only what
|
|
181
|
+
the visitor wrote. Leave `trust_proxy` unset there: counting the same proxy
|
|
182
|
+
again would believe the visitor's own entry.
|
|
183
|
+
- **Django** reads no forwarding header of its own: as gunicorn or Uvicorn
|
|
184
|
+
above, whichever runs it.
|
|
185
|
+
|
|
186
|
+
**What the rate limit counts:** requests per IPv4 address, or per IPv6
|
|
187
|
+
**/64** (one machine usually holds a whole /64, so counting addresses would let
|
|
188
|
+
it start afresh on every request); `::ffff:a.b.c.d` counts as `a.b.c.d`. The
|
|
189
|
+
same goes for bans and failed logins. Static files (`.css`, `.js`, images,
|
|
190
|
+
fonts, video, `.pdf`, `.wasm`, and anything under `/static/`) aren't counted,
|
|
191
|
+
so a page with 40 assets is one hit, not 41. A full table of counts (100,000)
|
|
192
|
+
drops its oldest entries, never everyone's.
|
|
124
193
|
|
|
125
194
|
**Rate limit with several workers:** each worker counts on its own, so the
|
|
126
195
|
limit is per worker. For one count, pass a shared counter:
|
|
@@ -145,6 +214,22 @@ minute or two. Floods (over the rate limit) are banned the same way.
|
|
|
145
214
|
backups: the list comes with the rules) are rule `probe`; in block mode the
|
|
146
215
|
address is also banned on this server, and the collector bans it on the
|
|
147
216
|
site's other servers.
|
|
217
|
+
|
|
218
|
+
**Browser check** (paid plans, switched on in the dashboard). A visitor's
|
|
219
|
+
first page of the day is a short "checking your browser" page that sets a
|
|
220
|
+
cookie with JavaScript and reloads; after that the site answers as usual for
|
|
221
|
+
about a day. Only a `GET` or `HEAD` that asks for a web page is checked: forms,
|
|
222
|
+
webhooks, payment callbacks, API calls (`Accept: application/json`), the paths
|
|
223
|
+
you exclude (`/api/`, …), your allowed addresses, search engines and the AI
|
|
224
|
+
crawlers that bring visits never see it. It stops cheap floods — scripts that
|
|
225
|
+
don't run JavaScript — and nothing more: a real browser, or a bot driving one,
|
|
226
|
+
gets through. It's a speed bump against scripts that don't run JavaScript: a
|
|
227
|
+
bot written for this product can read the pass from the page, which is why
|
|
228
|
+
the pass is bound to the visitor's address and the day. The cookie, `__sg_pass`, holds a signed day and nothing about the
|
|
229
|
+
visitor; it exists only to keep the site reachable, so it is strictly necessary
|
|
230
|
+
and needs no consent. Checked requests show on the Security page as rule
|
|
231
|
+
`challenge`.
|
|
232
|
+
|
|
148
233
|
## Error monitoring
|
|
149
234
|
|
|
150
235
|
Uncaught exceptions are sent on their own; request errors through the
|
|
@@ -163,7 +248,10 @@ exception logged at ERROR or above (`logger.exception(...)`, or
|
|
|
163
248
|
`exc_info=True`); it is still logged. An exception is sent once however many
|
|
164
249
|
of these see it, and one logged inside a Django, Flask or ASGI request keeps
|
|
165
250
|
that request's route. A batch the collector couldn't take is tried once more
|
|
166
|
-
a few seconds later.
|
|
251
|
+
a few seconds later. A visitor who hangs up mid-request (`BrokenPipeError`,
|
|
252
|
+
`ConnectionResetError`, Starlette's `ClientDisconnect`, Django's
|
|
253
|
+
`UnreadablePostError`) isn't sent as a server error. Messages are cut at
|
|
254
|
+
2,000 characters; the traceback keeps the detail.
|
|
167
255
|
|
|
168
256
|
What still can't be sent waits in a file in the temp folder and is sent when
|
|
169
257
|
it can be, after a restart too; each error keeps the time it happened.
|
|
@@ -202,6 +290,27 @@ Options: `server_name`, `root` (traceback paths relative to it),
|
|
|
202
290
|
`False` for none), `before_send(event, error)` (change an error, or return
|
|
203
291
|
`None` to drop it).
|
|
204
292
|
|
|
293
|
+
## Database queries
|
|
294
|
+
|
|
295
|
+
Server speed also shows which queries a route spends its time on (Health →
|
|
296
|
+
Server speed → Queries): per route and query, how many ran, how many failed and
|
|
297
|
+
how long they took. Every value is taken out of a query on your server before
|
|
298
|
+
it is counted (`WHERE email = ?`, `IN (?)`), and bound parameters are never
|
|
299
|
+
read, so no data leaves it.
|
|
300
|
+
|
|
301
|
+
| Library | Setup |
|
|
302
|
+
|---|---|
|
|
303
|
+
| Django | nothing: every connection, under the request or job that ran the query |
|
|
304
|
+
| SQLAlchemy | nothing: every engine (Flask-SQLAlchemy too) |
|
|
305
|
+
| pymongo | nothing, but **create the platform before your `MongoClient`**: pymongo only reports to clients made after the package is listening |
|
|
306
|
+
| Any other DB-API driver (psycopg, psycopg2, mysqlclient, PyMySQL, sqlite3...) | `conn = platform.watch_database(psycopg.connect(...))`, and use `conn` as before |
|
|
307
|
+
|
|
308
|
+
A query outside any request or job is counted as `JOB (background)`. Each
|
|
309
|
+
query is counted once: when SQLAlchemy or Django runs it over a watched
|
|
310
|
+
connection, they count it and the connection doesn't. It is on with
|
|
311
|
+
`performance`, and switched off with it in the dashboard; `queries=False`
|
|
312
|
+
keeps Server speed and leaves queries out (protocol: `sdk/protocols/queries.md`).
|
|
313
|
+
|
|
205
314
|
## Server analytics
|
|
206
315
|
|
|
207
316
|
For what only your server knows for sure. Not pageviews: the tracker counts those.
|
|
@@ -215,7 +324,10 @@ platform.lead({"email": "ana@example.com", "name": "Ana", "status": "qualified"}
|
|
|
215
324
|
```
|
|
216
325
|
|
|
217
326
|
`track()` sends in the background; `identify()` and `lead()` wait for the
|
|
218
|
-
answer (up to 5 seconds).
|
|
327
|
+
answer (up to 5 seconds). What you pass is taken as JSON when you pass it
|
|
328
|
+
(also `audit()`'s `details`): a date becomes ISO text, a `Decimal` a number,
|
|
329
|
+
a set a list, `NaN` null and anything else its `str()`, so one odd value can't
|
|
330
|
+
fail the batch it is sent in.
|
|
219
331
|
|
|
220
332
|
**Link to the visit:** send the tracker's session ID with your site's requests:
|
|
221
333
|
|
|
@@ -258,6 +370,12 @@ context = {
|
|
|
258
370
|
the send runs in a thread, so the event loop keeps turning.
|
|
259
371
|
- Works through the Django, WSGI (Flask, Bottle, Pyramid) and ASGI (FastAPI,
|
|
260
372
|
Starlette) middleware alike.
|
|
373
|
+
- A denied or banned address (and one in the blocklists or shared blocks)
|
|
374
|
+
gets nothing from it either; beacons are never rate-limited or checked.
|
|
375
|
+
- It runs on your request threads, so it never waits long: 2 seconds at most,
|
|
376
|
+
and after 3 failures in a row the collector is left alone for 30 seconds
|
|
377
|
+
(beacons are answered `502` at once, which the tracker treats as lost, and a
|
|
378
|
+
tracker file that couldn't be fetched isn't asked for again for 30 seconds).
|
|
261
379
|
- Switch it off with `proxy=False`, or in the dashboard (Site settings →
|
|
262
380
|
Server). Then load the tracker from us as before.
|
|
263
381
|
|
|
@@ -265,3 +383,12 @@ context = {
|
|
|
265
383
|
|
|
266
384
|
`platform.flush()` sends everything waiting (on Lambda, before returning).
|
|
267
385
|
`platform.close()` also stops the background threads.
|
|
386
|
+
|
|
387
|
+
When the process ends (and when a Celery, RQ or Dramatiq worker shuts down),
|
|
388
|
+
what's waiting is sent within **5 seconds** for every module together, so the
|
|
389
|
+
package never outlasts a container's or PM2's 30-second grace period: no retry
|
|
390
|
+
sleeps then, and the errors that weren't sent wait in the errors file for the
|
|
391
|
+
next start. `platform.flush_before_exit()` does the same from your own
|
|
392
|
+
shutdown hook. The errors file a resend takes is deleted only once it has
|
|
393
|
+
been sent, so a process killed meanwhile loses nothing (the next start takes
|
|
394
|
+
over a file left behind for 5 minutes).
|
|
@@ -27,7 +27,9 @@ ANALYTICS_PLATFORM = {
|
|
|
27
27
|
}
|
|
28
28
|
```
|
|
29
29
|
|
|
30
|
-
In your code: `from analytics_platform.django import get_platform`.
|
|
30
|
+
In your code: `from analytics_platform.django import get_platform`. The
|
|
31
|
+
package tells the collector it is a Django site (`analytics-platform-django/…`),
|
|
32
|
+
so attack patterns written for Django reach it.
|
|
31
33
|
|
|
32
34
|
## FastAPI / Starlette
|
|
33
35
|
|
|
@@ -49,6 +51,36 @@ platform.init_flask(app)
|
|
|
49
51
|
|
|
50
52
|
Other WSGI apps: `app = platform.wsgi(app)`; other ASGI apps: `app = platform.asgi(app)`.
|
|
51
53
|
|
|
54
|
+
## Celery, RQ and Dramatiq
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
platform.init_celery(app) # Celery: where the app is made
|
|
58
|
+
platform.init_dramatiq(broker) # Dramatiq: after dramatiq.set_broker(broker)
|
|
59
|
+
PlatformWorker = platform.rq_worker() # RQ: rq worker -w jobs.PlatformWorker
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Every task, message or job then runs as a [job](#error-monitoring) named
|
|
63
|
+
after the task, actor or function: its errors carry the route
|
|
64
|
+
`/(job) shop.tasks.charge`, one that raises is sent once (status 500), and
|
|
65
|
+
each run is timed (Server speed → Jobs). Prefork, solo, threads, gevent and
|
|
66
|
+
eventlet pools all work (Dramatiq's `dramatiq-gevent` too), and so does
|
|
67
|
+
Celery's `task_always_eager`.
|
|
68
|
+
|
|
69
|
+
- A failure that will be retried is timed as failed but not sent: the error
|
|
70
|
+
is sent when the last attempt fails. Celery's `Ignore` counts as done,
|
|
71
|
+
`Reject` as failed.
|
|
72
|
+
- A run cut short (`SystemExit`, a hard time limit, the worker stopping
|
|
73
|
+
mid-task) is timed as failed. Time limits are sent as errors; `SystemExit`
|
|
74
|
+
isn't.
|
|
75
|
+
- A Celery or Dramatiq process killed outright (`SIGKILL`, out of memory)
|
|
76
|
+
can't be timed: nothing of the run is left to say how long it took. RQ's
|
|
77
|
+
worker outlives the process it runs a job in, so it sends and times that
|
|
78
|
+
job itself (`WorkHorseKilled`).
|
|
79
|
+
- What's waiting is sent as each worker process ends. RQ runs each job in a
|
|
80
|
+
process of its own, so it sends after every job; only the worker checks
|
|
81
|
+
in (Health → All systems), not a process per job.
|
|
82
|
+
- Don't also wrap a task in `platform.job()`: it would be timed twice.
|
|
83
|
+
|
|
52
84
|
## Modules
|
|
53
85
|
|
|
54
86
|
| Module | What it does | Default |
|
|
@@ -63,7 +95,7 @@ Other WSGI apps: `app = platform.wsgi(app)`; other ASGI apps: `app = platform.as
|
|
|
63
95
|
| `maintenance` | A "back soon" page while maintenance mode is on in the dashboard | on |
|
|
64
96
|
| `headers` | Adds the security headers you pick on the Security page; your own headers win | on |
|
|
65
97
|
| `forms` | Checks forms before you accept them: `platform.check_form(request)` | on |
|
|
66
|
-
| `performance` | Response times and errors of every route (Health → Server speed): Django, Flask and FastAPI route patterns | 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 |
|
|
67
99
|
| `crawlers` | Which search engines and AI bots fetch your pages (Health → Crawlers). Django counts them for you; elsewhere call `platform.record_crawler(...)` | on |
|
|
68
100
|
| `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 |
|
|
69
101
|
| `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 |
|
|
@@ -94,7 +126,44 @@ Options: `mode` (`"monitor"` or `"block"`, overrides the dashboard),
|
|
|
94
126
|
**The visitor's address:** behind a proxy, every request comes from the proxy
|
|
95
127
|
unless you set `trust_proxy`: `1` for one proxy or load balancer, `2` for
|
|
96
128
|
Cloudflare in front of a load balancer, `"cloudflare"` when every request comes
|
|
97
|
-
through Cloudflare
|
|
129
|
+
through Cloudflare (`CF-Connecting-IP` is believed only from Cloudflare's own
|
|
130
|
+
addresses: anyone who reaches your server directly can send it).
|
|
131
|
+
`X-Forwarded-For` is read from the right, as each proxy appends what it saw;
|
|
132
|
+
a port on an entry (`203.0.113.5:54321`, `[2001:db8::1]:443`, as Azure sends
|
|
133
|
+
them) is dropped, and an entry that isn't an address (`unknown`, a name)
|
|
134
|
+
means the connection's own address is used rather than no check at all.
|
|
135
|
+
|
|
136
|
+
When something in front of the package already works out the address, count
|
|
137
|
+
each proxy once:
|
|
138
|
+
|
|
139
|
+
- **gunicorn** never changes `REMOTE_ADDR` (`--forwarded-allow-ips` is about
|
|
140
|
+
`X-Forwarded-Proto` only): set `trust_proxy`.
|
|
141
|
+
- **Uvicorn** (`--proxy-headers`, on by default, trusting the addresses in
|
|
142
|
+
`--forwarded-allow-ips`) puts the visitor's address in place itself, and
|
|
143
|
+
leaves the header as it was. Leave `trust_proxy` unset when
|
|
144
|
+
`--forwarded-allow-ips` lists your proxies' addresses. With
|
|
145
|
+
`--forwarded-allow-ips='*'` (`FORWARDED_ALLOW_IPS=*`, common in Docker images)
|
|
146
|
+
Uvicorn takes the **leftmost** entry, which the visitor writes: there, set
|
|
147
|
+
`trust_proxy` to your number of proxies, and the package reads the header
|
|
148
|
+
itself from the right.
|
|
149
|
+
- **werkzeug's `ProxyFix`**: wrapped inside the package's middleware
|
|
150
|
+
(`app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1)` before `init_flask`), the
|
|
151
|
+
package uses its `x_for` when `trust_proxy` is unset; wrapped outside, it has
|
|
152
|
+
already set `REMOTE_ADDR` and unset `trust_proxy` uses that.
|
|
153
|
+
- **Apache's `mod_remoteip`** (`mod_wsgi`) sets `REMOTE_ADDR` to the visitor
|
|
154
|
+
and *removes the entries it used* from `X-Forwarded-For`, leaving only what
|
|
155
|
+
the visitor wrote. Leave `trust_proxy` unset there: counting the same proxy
|
|
156
|
+
again would believe the visitor's own entry.
|
|
157
|
+
- **Django** reads no forwarding header of its own: as gunicorn or Uvicorn
|
|
158
|
+
above, whichever runs it.
|
|
159
|
+
|
|
160
|
+
**What the rate limit counts:** requests per IPv4 address, or per IPv6
|
|
161
|
+
**/64** (one machine usually holds a whole /64, so counting addresses would let
|
|
162
|
+
it start afresh on every request); `::ffff:a.b.c.d` counts as `a.b.c.d`. The
|
|
163
|
+
same goes for bans and failed logins. Static files (`.css`, `.js`, images,
|
|
164
|
+
fonts, video, `.pdf`, `.wasm`, and anything under `/static/`) aren't counted,
|
|
165
|
+
so a page with 40 assets is one hit, not 41. A full table of counts (100,000)
|
|
166
|
+
drops its oldest entries, never everyone's.
|
|
98
167
|
|
|
99
168
|
**Rate limit with several workers:** each worker counts on its own, so the
|
|
100
169
|
limit is per worker. For one count, pass a shared counter:
|
|
@@ -119,6 +188,22 @@ minute or two. Floods (over the rate limit) are banned the same way.
|
|
|
119
188
|
backups: the list comes with the rules) are rule `probe`; in block mode the
|
|
120
189
|
address is also banned on this server, and the collector bans it on the
|
|
121
190
|
site's other servers.
|
|
191
|
+
|
|
192
|
+
**Browser check** (paid plans, switched on in the dashboard). A visitor's
|
|
193
|
+
first page of the day is a short "checking your browser" page that sets a
|
|
194
|
+
cookie with JavaScript and reloads; after that the site answers as usual for
|
|
195
|
+
about a day. Only a `GET` or `HEAD` that asks for a web page is checked: forms,
|
|
196
|
+
webhooks, payment callbacks, API calls (`Accept: application/json`), the paths
|
|
197
|
+
you exclude (`/api/`, …), your allowed addresses, search engines and the AI
|
|
198
|
+
crawlers that bring visits never see it. It stops cheap floods — scripts that
|
|
199
|
+
don't run JavaScript — and nothing more: a real browser, or a bot driving one,
|
|
200
|
+
gets through. It's a speed bump against scripts that don't run JavaScript: a
|
|
201
|
+
bot written for this product can read the pass from the page, which is why
|
|
202
|
+
the pass is bound to the visitor's address and the day. The cookie, `__sg_pass`, holds a signed day and nothing about the
|
|
203
|
+
visitor; it exists only to keep the site reachable, so it is strictly necessary
|
|
204
|
+
and needs no consent. Checked requests show on the Security page as rule
|
|
205
|
+
`challenge`.
|
|
206
|
+
|
|
122
207
|
## Error monitoring
|
|
123
208
|
|
|
124
209
|
Uncaught exceptions are sent on their own; request errors through the
|
|
@@ -137,7 +222,10 @@ exception logged at ERROR or above (`logger.exception(...)`, or
|
|
|
137
222
|
`exc_info=True`); it is still logged. An exception is sent once however many
|
|
138
223
|
of these see it, and one logged inside a Django, Flask or ASGI request keeps
|
|
139
224
|
that request's route. A batch the collector couldn't take is tried once more
|
|
140
|
-
a few seconds later.
|
|
225
|
+
a few seconds later. A visitor who hangs up mid-request (`BrokenPipeError`,
|
|
226
|
+
`ConnectionResetError`, Starlette's `ClientDisconnect`, Django's
|
|
227
|
+
`UnreadablePostError`) isn't sent as a server error. Messages are cut at
|
|
228
|
+
2,000 characters; the traceback keeps the detail.
|
|
141
229
|
|
|
142
230
|
What still can't be sent waits in a file in the temp folder and is sent when
|
|
143
231
|
it can be, after a restart too; each error keeps the time it happened.
|
|
@@ -176,6 +264,27 @@ Options: `server_name`, `root` (traceback paths relative to it),
|
|
|
176
264
|
`False` for none), `before_send(event, error)` (change an error, or return
|
|
177
265
|
`None` to drop it).
|
|
178
266
|
|
|
267
|
+
## Database queries
|
|
268
|
+
|
|
269
|
+
Server speed also shows which queries a route spends its time on (Health →
|
|
270
|
+
Server speed → Queries): per route and query, how many ran, how many failed and
|
|
271
|
+
how long they took. Every value is taken out of a query on your server before
|
|
272
|
+
it is counted (`WHERE email = ?`, `IN (?)`), and bound parameters are never
|
|
273
|
+
read, so no data leaves it.
|
|
274
|
+
|
|
275
|
+
| Library | Setup |
|
|
276
|
+
|---|---|
|
|
277
|
+
| Django | nothing: every connection, under the request or job that ran the query |
|
|
278
|
+
| SQLAlchemy | nothing: every engine (Flask-SQLAlchemy too) |
|
|
279
|
+
| pymongo | nothing, but **create the platform before your `MongoClient`**: pymongo only reports to clients made after the package is listening |
|
|
280
|
+
| Any other DB-API driver (psycopg, psycopg2, mysqlclient, PyMySQL, sqlite3...) | `conn = platform.watch_database(psycopg.connect(...))`, and use `conn` as before |
|
|
281
|
+
|
|
282
|
+
A query outside any request or job is counted as `JOB (background)`. Each
|
|
283
|
+
query is counted once: when SQLAlchemy or Django runs it over a watched
|
|
284
|
+
connection, they count it and the connection doesn't. It is on with
|
|
285
|
+
`performance`, and switched off with it in the dashboard; `queries=False`
|
|
286
|
+
keeps Server speed and leaves queries out (protocol: `sdk/protocols/queries.md`).
|
|
287
|
+
|
|
179
288
|
## Server analytics
|
|
180
289
|
|
|
181
290
|
For what only your server knows for sure. Not pageviews: the tracker counts those.
|
|
@@ -189,7 +298,10 @@ platform.lead({"email": "ana@example.com", "name": "Ana", "status": "qualified"}
|
|
|
189
298
|
```
|
|
190
299
|
|
|
191
300
|
`track()` sends in the background; `identify()` and `lead()` wait for the
|
|
192
|
-
answer (up to 5 seconds).
|
|
301
|
+
answer (up to 5 seconds). What you pass is taken as JSON when you pass it
|
|
302
|
+
(also `audit()`'s `details`): a date becomes ISO text, a `Decimal` a number,
|
|
303
|
+
a set a list, `NaN` null and anything else its `str()`, so one odd value can't
|
|
304
|
+
fail the batch it is sent in.
|
|
193
305
|
|
|
194
306
|
**Link to the visit:** send the tracker's session ID with your site's requests:
|
|
195
307
|
|
|
@@ -232,6 +344,12 @@ context = {
|
|
|
232
344
|
the send runs in a thread, so the event loop keeps turning.
|
|
233
345
|
- Works through the Django, WSGI (Flask, Bottle, Pyramid) and ASGI (FastAPI,
|
|
234
346
|
Starlette) middleware alike.
|
|
347
|
+
- A denied or banned address (and one in the blocklists or shared blocks)
|
|
348
|
+
gets nothing from it either; beacons are never rate-limited or checked.
|
|
349
|
+
- It runs on your request threads, so it never waits long: 2 seconds at most,
|
|
350
|
+
and after 3 failures in a row the collector is left alone for 30 seconds
|
|
351
|
+
(beacons are answered `502` at once, which the tracker treats as lost, and a
|
|
352
|
+
tracker file that couldn't be fetched isn't asked for again for 30 seconds).
|
|
235
353
|
- Switch it off with `proxy=False`, or in the dashboard (Site settings →
|
|
236
354
|
Server). Then load the tracker from us as before.
|
|
237
355
|
|
|
@@ -239,3 +357,12 @@ context = {
|
|
|
239
357
|
|
|
240
358
|
`platform.flush()` sends everything waiting (on Lambda, before returning).
|
|
241
359
|
`platform.close()` also stops the background threads.
|
|
360
|
+
|
|
361
|
+
When the process ends (and when a Celery, RQ or Dramatiq worker shuts down),
|
|
362
|
+
what's waiting is sent within **5 seconds** for every module together, so the
|
|
363
|
+
package never outlasts a container's or PM2's 30-second grace period: no retry
|
|
364
|
+
sleeps then, and the errors that weren't sent wait in the errors file for the
|
|
365
|
+
next start. `platform.flush_before_exit()` does the same from your own
|
|
366
|
+
shutdown hook. The errors file a resend takes is deleted only once it has
|
|
367
|
+
been sent, so a process killed meanwhile loses nothing (the next start takes
|
|
368
|
+
over a file left behind for 5 minutes).
|