analytics-platform 0.3.5__tar.gz → 0.3.6__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.5 → analytics_platform-0.3.6}/PKG-INFO +20 -1
- {analytics_platform-0.3.5 → analytics_platform-0.3.6}/README.md +19 -0
- {analytics_platform-0.3.5 → analytics_platform-0.3.6}/analytics_platform/__init__.py +8 -2
- {analytics_platform-0.3.5 → analytics_platform-0.3.6}/analytics_platform/core.py +1 -1
- {analytics_platform-0.3.5 → analytics_platform-0.3.6}/analytics_platform/queries.py +416 -4
- {analytics_platform-0.3.5 → analytics_platform-0.3.6}/analytics_platform/queues.py +5 -1
- {analytics_platform-0.3.5 → analytics_platform-0.3.6}/analytics_platform.egg-info/PKG-INFO +20 -1
- {analytics_platform-0.3.5 → analytics_platform-0.3.6}/analytics_platform.egg-info/SOURCES.txt +1 -0
- {analytics_platform-0.3.5 → analytics_platform-0.3.6}/pyproject.toml +1 -1
- analytics_platform-0.3.6/tests/test_redis.py +428 -0
- {analytics_platform-0.3.5 → analytics_platform-0.3.6}/LICENSE +0 -0
- {analytics_platform-0.3.5 → analytics_platform-0.3.6}/analytics_platform/analytics.py +0 -0
- {analytics_platform-0.3.5 → analytics_platform-0.3.6}/analytics_platform/audit.py +0 -0
- {analytics_platform-0.3.5 → analytics_platform-0.3.6}/analytics_platform/calls.py +0 -0
- {analytics_platform-0.3.5 → analytics_platform-0.3.6}/analytics_platform/celery.py +0 -0
- {analytics_platform-0.3.5 → analytics_platform-0.3.6}/analytics_platform/challenge.py +0 -0
- {analytics_platform-0.3.5 → analytics_platform-0.3.6}/analytics_platform/crawlers.py +0 -0
- {analytics_platform-0.3.5 → analytics_platform-0.3.6}/analytics_platform/django.py +0 -0
- {analytics_platform-0.3.5 → analytics_platform-0.3.6}/analytics_platform/dramatiq.py +0 -0
- {analytics_platform-0.3.5 → analytics_platform-0.3.6}/analytics_platform/errors.py +0 -0
- {analytics_platform-0.3.5 → analytics_platform-0.3.6}/analytics_platform/firewall.py +0 -0
- {analytics_platform-0.3.5 → analytics_platform-0.3.6}/analytics_platform/flags.py +0 -0
- {analytics_platform-0.3.5 → analytics_platform-0.3.6}/analytics_platform/forms.py +0 -0
- {analytics_platform-0.3.5 → analytics_platform-0.3.6}/analytics_platform/inventory.py +0 -0
- {analytics_platform-0.3.5 → analytics_platform-0.3.6}/analytics_platform/maintenance.py +0 -0
- {analytics_platform-0.3.5 → analytics_platform-0.3.6}/analytics_platform/performance.py +0 -0
- {analytics_platform-0.3.5 → analytics_platform-0.3.6}/analytics_platform/posture.py +0 -0
- {analytics_platform-0.3.5 → analytics_platform-0.3.6}/analytics_platform/proxy.py +0 -0
- {analytics_platform-0.3.5 → analytics_platform-0.3.6}/analytics_platform/request_rules.py +0 -0
- {analytics_platform-0.3.5 → analytics_platform-0.3.6}/analytics_platform/resources.py +0 -0
- {analytics_platform-0.3.5 → analytics_platform-0.3.6}/analytics_platform/rq.py +0 -0
- {analytics_platform-0.3.5 → analytics_platform-0.3.6}/analytics_platform.egg-info/dependency_links.txt +0 -0
- {analytics_platform-0.3.5 → analytics_platform-0.3.6}/analytics_platform.egg-info/top_level.txt +0 -0
- {analytics_platform-0.3.5 → analytics_platform-0.3.6}/setup.cfg +0 -0
- {analytics_platform-0.3.5 → analytics_platform-0.3.6}/tests/test_calls_queues.py +0 -0
- {analytics_platform-0.3.5 → analytics_platform-0.3.6}/tests/test_celery.py +0 -0
- {analytics_platform-0.3.5 → analytics_platform-0.3.6}/tests/test_challenge.py +0 -0
- {analytics_platform-0.3.5 → analytics_platform-0.3.6}/tests/test_checkin.py +0 -0
- {analytics_platform-0.3.5 → analytics_platform-0.3.6}/tests/test_crawlers.py +0 -0
- {analytics_platform-0.3.5 → analytics_platform-0.3.6}/tests/test_errors.py +0 -0
- {analytics_platform-0.3.5 → analytics_platform-0.3.6}/tests/test_firewall.py +0 -0
- {analytics_platform-0.3.5 → analytics_platform-0.3.6}/tests/test_hardening.py +0 -0
- {analytics_platform-0.3.5 → analytics_platform-0.3.6}/tests/test_limits.py +0 -0
- {analytics_platform-0.3.5 → analytics_platform-0.3.6}/tests/test_page_limits.py +0 -0
- {analytics_platform-0.3.5 → analytics_platform-0.3.6}/tests/test_proxy.py +0 -0
- {analytics_platform-0.3.5 → analytics_platform-0.3.6}/tests/test_queries.py +0 -0
- {analytics_platform-0.3.5 → analytics_platform-0.3.6}/tests/test_request_rules.py +0 -0
- {analytics_platform-0.3.5 → analytics_platform-0.3.6}/tests/test_resources.py +0 -0
- {analytics_platform-0.3.5 → analytics_platform-0.3.6}/tests/test_rq_dramatiq.py +0 -0
- {analytics_platform-0.3.5 → analytics_platform-0.3.6}/tests/test_static_late.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: analytics-platform
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.6
|
|
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
|
|
@@ -317,6 +317,8 @@ read, so no data leaves it.
|
|
|
317
317
|
| pymongo | nothing, but **create the platform before your `MongoClient`**: pymongo only reports to clients made after the package is listening |
|
|
318
318
|
| Any other DB-API driver (psycopg, psycopg2, mysqlclient, PyMySQL, sqlite3...) | `conn = platform.watch_database(psycopg.connect(...))`, and use `conn` as before |
|
|
319
319
|
| ClickHouse: clickhouse-connect, clickhouse-driver | `client = platform.watch_database(clickhouse_connect.get_client(...))` (or a `clickhouse_driver.Client`), and use `client` as before |
|
|
320
|
+
| Redis: redis-py (`Redis`, `RedisCluster`, sync or `redis.asyncio`) | `platform.watch_database(r)`: the client is watched in place, so keep using `r` |
|
|
321
|
+
| Django's cache on Redis (Django's own `RedisCache`, or django-redis) | `platform.watch_database(caches["default"])`, once at start-up (in `AppConfig.ready()`) |
|
|
320
322
|
|
|
321
323
|
A query outside any request or job is counted as `JOB (background)`. Each
|
|
322
324
|
query is counted once: when SQLAlchemy or Django runs it over a watched
|
|
@@ -331,6 +333,23 @@ they answer. clickhouse-driver's `execute()` likewise. The streaming
|
|
|
331
333
|
queries (`query_rows_stream()`, `execute_iter()`) aren't timed. Only the
|
|
332
334
|
client you pass is wrapped; the library itself is left alone.
|
|
333
335
|
|
|
336
|
+
Redis: each command is counted by its name and the shape of its key (`GET
|
|
337
|
+
user:?:cart`); no other argument or value is read. A pipeline or transaction
|
|
338
|
+
is one call (`PIPELINE GET user:?`, `MULTI SET cart:?`), timed as a whole.
|
|
339
|
+
Commands that wait (`BLPOP`, `XREAD BLOCK`, subscribing) aren't timed, and
|
|
340
|
+
nor are this package's own reads of your RQ queues. An error reply counts
|
|
341
|
+
as failed; a nil reply doesn't.
|
|
342
|
+
|
|
343
|
+
```python
|
|
344
|
+
r = redis.Redis.from_url(REDIS_URL)
|
|
345
|
+
platform.watch_database(r)
|
|
346
|
+
|
|
347
|
+
# Django (settings: CACHES backend django.core.cache.backends.redis.RedisCache
|
|
348
|
+
# or django_redis.cache.RedisCache), in your AppConfig.ready():
|
|
349
|
+
from django.core.cache import caches
|
|
350
|
+
platform.watch_database(caches["default"])
|
|
351
|
+
```
|
|
352
|
+
|
|
334
353
|
## Outgoing calls
|
|
335
354
|
|
|
336
355
|
Health → Server speed → Calls shows how long each route waited on other
|
|
@@ -291,6 +291,8 @@ read, so no data leaves it.
|
|
|
291
291
|
| pymongo | nothing, but **create the platform before your `MongoClient`**: pymongo only reports to clients made after the package is listening |
|
|
292
292
|
| Any other DB-API driver (psycopg, psycopg2, mysqlclient, PyMySQL, sqlite3...) | `conn = platform.watch_database(psycopg.connect(...))`, and use `conn` as before |
|
|
293
293
|
| ClickHouse: clickhouse-connect, clickhouse-driver | `client = platform.watch_database(clickhouse_connect.get_client(...))` (or a `clickhouse_driver.Client`), and use `client` as before |
|
|
294
|
+
| Redis: redis-py (`Redis`, `RedisCluster`, sync or `redis.asyncio`) | `platform.watch_database(r)`: the client is watched in place, so keep using `r` |
|
|
295
|
+
| Django's cache on Redis (Django's own `RedisCache`, or django-redis) | `platform.watch_database(caches["default"])`, once at start-up (in `AppConfig.ready()`) |
|
|
294
296
|
|
|
295
297
|
A query outside any request or job is counted as `JOB (background)`. Each
|
|
296
298
|
query is counted once: when SQLAlchemy or Django runs it over a watched
|
|
@@ -305,6 +307,23 @@ they answer. clickhouse-driver's `execute()` likewise. The streaming
|
|
|
305
307
|
queries (`query_rows_stream()`, `execute_iter()`) aren't timed. Only the
|
|
306
308
|
client you pass is wrapped; the library itself is left alone.
|
|
307
309
|
|
|
310
|
+
Redis: each command is counted by its name and the shape of its key (`GET
|
|
311
|
+
user:?:cart`); no other argument or value is read. A pipeline or transaction
|
|
312
|
+
is one call (`PIPELINE GET user:?`, `MULTI SET cart:?`), timed as a whole.
|
|
313
|
+
Commands that wait (`BLPOP`, `XREAD BLOCK`, subscribing) aren't timed, and
|
|
314
|
+
nor are this package's own reads of your RQ queues. An error reply counts
|
|
315
|
+
as failed; a nil reply doesn't.
|
|
316
|
+
|
|
317
|
+
```python
|
|
318
|
+
r = redis.Redis.from_url(REDIS_URL)
|
|
319
|
+
platform.watch_database(r)
|
|
320
|
+
|
|
321
|
+
# Django (settings: CACHES backend django.core.cache.backends.redis.RedisCache
|
|
322
|
+
# or django_redis.cache.RedisCache), in your AppConfig.ready():
|
|
323
|
+
from django.core.cache import caches
|
|
324
|
+
platform.watch_database(caches["default"])
|
|
325
|
+
```
|
|
326
|
+
|
|
308
327
|
## Outgoing calls
|
|
309
328
|
|
|
310
329
|
Health → Server speed → Calls shows how long each route waited on other
|
|
@@ -342,12 +342,18 @@ class Platform:
|
|
|
342
342
|
sqlite3...) or a ClickHouse client (clickhouse-connect,
|
|
343
343
|
clickhouse-driver) whose queries are timed for Server speed: use what
|
|
344
344
|
it returns in its place. db: "postgresql", "mysql"... when the driver
|
|
345
|
-
doesn't say. Django, SQLAlchemy and pymongo need nothing of this.
|
|
345
|
+
doesn't say. Django, SQLAlchemy and pymongo need nothing of this.
|
|
346
|
+
A redis-py client (Redis, RedisCluster, sync or asyncio) or a Django
|
|
347
|
+
cache on Redis is watched in place and handed back as itself."""
|
|
346
348
|
queries = self._queries()
|
|
347
349
|
if queries is None:
|
|
348
350
|
return connection
|
|
349
351
|
from .queries import watch_database
|
|
350
|
-
|
|
352
|
+
try:
|
|
353
|
+
return watch_database(connection, db)
|
|
354
|
+
except Exception as error:
|
|
355
|
+
self.core.log("performance", "watch_database: its queries aren't timed:", error)
|
|
356
|
+
return connection
|
|
351
357
|
|
|
352
358
|
def watch_http(self, client):
|
|
353
359
|
"""A requests Session, an httpx Client or AsyncClient, or an httpx
|
|
@@ -15,6 +15,8 @@ Caught by, with nothing to set up beyond the platform itself:
|
|
|
15
15
|
sqlite3...): conn = platform.watch_database(conn).
|
|
16
16
|
- ClickHouse: a clickhouse-connect client (query, command, insert...) or a
|
|
17
17
|
clickhouse-driver Client (execute): client = platform.watch_database(client).
|
|
18
|
+
- Redis: a redis-py client (Redis, RedisCluster, sync or asyncio), watched in
|
|
19
|
+
place: platform.watch_database(r); a Django cache on Redis the same way.
|
|
18
20
|
|
|
19
21
|
A query is timed at one layer only: the outer one (Django, SQLAlchemy) marks
|
|
20
22
|
it as being timed, and a watched connection under it leaves it alone.
|
|
@@ -24,6 +26,7 @@ alongside, by call method and host (calls.py has the hooks), and go to
|
|
|
24
26
|
POST /events/server-calls; calls=False leaves them out.
|
|
25
27
|
"""
|
|
26
28
|
import contextvars
|
|
29
|
+
import inspect
|
|
27
30
|
import json
|
|
28
31
|
import os
|
|
29
32
|
import re
|
|
@@ -32,6 +35,7 @@ import threading
|
|
|
32
35
|
import time
|
|
33
36
|
import weakref
|
|
34
37
|
from collections.abc import Mapping
|
|
38
|
+
from contextlib import contextmanager
|
|
35
39
|
from datetime import datetime, timezone
|
|
36
40
|
from importlib.util import find_spec
|
|
37
41
|
|
|
@@ -41,7 +45,7 @@ MAX_STATEMENT = 1000
|
|
|
41
45
|
OTHER_STATEMENT = "(other)"
|
|
42
46
|
BACKGROUND = ("JOB", "(background)")
|
|
43
47
|
# The databases a row may name; anything else is "other".
|
|
44
|
-
DATABASES = frozenset(("postgresql", "mysql", "mariadb", "sqlite", "mssql", "oracle", "clickhouse", "mongodb", "other"))
|
|
48
|
+
DATABASES = frozenset(("postgresql", "mysql", "mariadb", "sqlite", "mssql", "oracle", "clickhouse", "mongodb", "redis", "other"))
|
|
45
49
|
# Past this many statements in one request, new ones are one "(other)".
|
|
46
50
|
PER_REQUEST = 50
|
|
47
51
|
# Past MAX_KEYS in a minute new statements fold into "(other)"; past
|
|
@@ -64,6 +68,9 @@ current_queries = contextvars.ContextVar("analytics_platform_queries", default=N
|
|
|
64
68
|
# True while an outer layer (Django, SQLAlchemy) times a query, so a watched
|
|
65
69
|
# connection under it doesn't count it again.
|
|
66
70
|
_timing = contextvars.ContextVar("analytics_platform_query_timing", default=False)
|
|
71
|
+
# True while the package reads Redis for itself (a queue's depth, queues.py):
|
|
72
|
+
# a watched client it shares with the app leaves those commands out.
|
|
73
|
+
_own_redis = contextvars.ContextVar("analytics_platform_own_redis", default=False)
|
|
67
74
|
|
|
68
75
|
# ---- Normalizing: a port of expresscollect/Analytics/queryText.js ----
|
|
69
76
|
|
|
@@ -264,6 +271,142 @@ def _mongo_statement(text):
|
|
|
264
271
|
return " ".join(part for part in (command, collection, shape) if part)
|
|
265
272
|
|
|
266
273
|
|
|
274
|
+
# Redis: the command and its first key's shape, never an argument or value
|
|
275
|
+
# (queries.md, "Redis"). A key keeps each ":" segment that is only letters
|
|
276
|
+
# and "_" (32 at most); every other segment is "?", so IDs, slugs, emails and
|
|
277
|
+
# hashes never leave the server: user:42:cart -> user:?:cart.
|
|
278
|
+
_REDIS_WORD = re.compile(r"[A-Za-z][A-Za-z0-9_.|-]{0,39}\Z")
|
|
279
|
+
_REDIS_SEGMENT = re.compile(r"[A-Za-z_]{1,32}\Z")
|
|
280
|
+
# Commands whose second word is a subcommand (CLIENT SETNAME, XINFO STREAM).
|
|
281
|
+
_REDIS_CONTAINERS = frozenset(("ACL", "CLIENT", "CLUSTER", "COMMAND", "CONFIG", "DEBUG", "FUNCTION", "LATENCY", "MEMORY",
|
|
282
|
+
"MODULE", "OBJECT", "PUBSUB", "SCRIPT", "SLOWLOG", "XGROUP", "XINFO"))
|
|
283
|
+
# Of those, the ones with a key after the subcommand.
|
|
284
|
+
_REDIS_SUB_KEY = frozenset(("OBJECT", "MEMORY", "XGROUP", "XINFO"))
|
|
285
|
+
# No key: what follows is a password, a message, a number or nothing.
|
|
286
|
+
_REDIS_NO_KEY = frozenset(("PING", "ECHO", "INFO", "AUTH", "HELLO", "SELECT", "QUIT", "RESET", "MULTI", "EXEC", "DISCARD",
|
|
287
|
+
"UNWATCH", "DBSIZE", "TIME", "FLUSHDB", "FLUSHALL", "SAVE", "BGSAVE", "BGREWRITEAOF", "LASTSAVE",
|
|
288
|
+
"SCAN", "RANDOMKEY", "SWAPDB", "SHUTDOWN", "ROLE", "REPLICAOF", "SLAVEOF", "READONLY", "READWRITE",
|
|
289
|
+
"LOLWUT", "FAILOVER", "PSYNC", "SYNC", "REPLCONF", "MIGRATE"))
|
|
290
|
+
# Script calls: script or name, then how many keys, then the keys.
|
|
291
|
+
_REDIS_SCRIPTS = frozenset(("EVAL", "EVALSHA", "EVAL_RO", "EVALSHA_RO", "FCALL", "FCALL_RO"))
|
|
292
|
+
# How many keys first, then the keys.
|
|
293
|
+
_REDIS_NUMKEYS = frozenset(("ZUNION", "ZINTER", "ZDIFF", "SINTERCARD", "ZINTERCARD", "LMPOP", "ZMPOP"))
|
|
294
|
+
# Waiting, not working: a queue worker sits in these for seconds, so timing
|
|
295
|
+
# them would put every idle worker at the top of the slow list.
|
|
296
|
+
_REDIS_WAITING = frozenset(("BLPOP", "BRPOP", "BRPOPLPUSH", "BLMOVE", "BLMPOP", "BZPOPMIN", "BZPOPMAX", "BZMPOP", "WAIT",
|
|
297
|
+
"WAITAOF", "SUBSCRIBE", "PSUBSCRIBE", "SSUBSCRIBE", "UNSUBSCRIBE", "PUNSUBSCRIBE",
|
|
298
|
+
"SUNSUBSCRIBE", "MONITOR"))
|
|
299
|
+
_REDIS_PREFIXES = frozenset(("PIPELINE", "MULTI"))
|
|
300
|
+
# ASCII only, as JavaScript's /[a-z]/: str.upper() would change "ß" and "ı".
|
|
301
|
+
_ASCII_UPPER = str.maketrans("abcdefghijklmnopqrstuvwxyz", "ABCDEFGHIJKLMNOPQRSTUVWXYZ")
|
|
302
|
+
_JS_DECIMAL = re.compile(r"[+-]?(?:[0-9]+\.?[0-9]*|\.[0-9]+)(?:[eE][+-]?[0-9]+)?\Z")
|
|
303
|
+
_JS_RADIX = re.compile(r"0[xXoObB][0-9a-fA-F]+\Z")
|
|
304
|
+
_SPACE_RUN = re.compile(_S + "+")
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _upper(text):
|
|
308
|
+
return text.translate(_ASCII_UPPER)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _positive(value):
|
|
312
|
+
"""JavaScript's Number(value) > 0, for what a command's argument can be."""
|
|
313
|
+
if isinstance(value, bool):
|
|
314
|
+
return value
|
|
315
|
+
if isinstance(value, (int, float)):
|
|
316
|
+
return value > 0
|
|
317
|
+
if not isinstance(value, str):
|
|
318
|
+
return False
|
|
319
|
+
text = value.strip(_SPACE)
|
|
320
|
+
if _JS_DECIMAL.match(text):
|
|
321
|
+
return float(text) > 0
|
|
322
|
+
if _JS_RADIX.match(text):
|
|
323
|
+
try:
|
|
324
|
+
return int(text, 0) > 0
|
|
325
|
+
except ValueError:
|
|
326
|
+
return False
|
|
327
|
+
return text in ("Infinity", "+Infinity")
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def redis_key_shape(key):
|
|
331
|
+
# An empty key is "?" too: "GET " would come back from the collector as "GET".
|
|
332
|
+
if not isinstance(key, str) or key == "":
|
|
333
|
+
return "?"
|
|
334
|
+
return ":".join(part if part == "" or _REDIS_SEGMENT.match(part) else "?" for part in key.split(":"))
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def redis_command(args):
|
|
338
|
+
"""A command as the client sends it (["GET", "user:42"]) -> "GET user:?",
|
|
339
|
+
or None for one that isn't timed (blocking, or not a command)."""
|
|
340
|
+
if not isinstance(args, (list, tuple)) or not args or not isinstance(args[0], str) or not _REDIS_WORD.match(args[0]):
|
|
341
|
+
return None
|
|
342
|
+
name = _upper(args[0])
|
|
343
|
+
|
|
344
|
+
def word(i):
|
|
345
|
+
return _upper(args[i]) if isinstance(args[i], str) else ""
|
|
346
|
+
|
|
347
|
+
if name in _REDIS_WAITING:
|
|
348
|
+
return None
|
|
349
|
+
command = name
|
|
350
|
+
key = None
|
|
351
|
+
if name in _REDIS_CONTAINERS:
|
|
352
|
+
if len(args) > 1 and isinstance(args[1], str) and _REDIS_WORD.match(args[1]):
|
|
353
|
+
command = name + " " + _upper(args[1])
|
|
354
|
+
# Only after a subcommand: "OBJECT user" would read back as OBJECT USER.
|
|
355
|
+
if name in _REDIS_SUB_KEY:
|
|
356
|
+
key = 2
|
|
357
|
+
elif name in _REDIS_SCRIPTS:
|
|
358
|
+
if len(args) > 2 and _positive(args[2]):
|
|
359
|
+
key = 3
|
|
360
|
+
elif name in _REDIS_NUMKEYS:
|
|
361
|
+
if len(args) > 1 and _positive(args[1]):
|
|
362
|
+
key = 2
|
|
363
|
+
elif name == "BITOP":
|
|
364
|
+
key = 2
|
|
365
|
+
elif name in ("XREAD", "XREADGROUP"):
|
|
366
|
+
# Its options, then STREAMS and the keys. XREADGROUP's group and
|
|
367
|
+
# consumer come first and may be named anything, as may a stream ("block").
|
|
368
|
+
at = -1
|
|
369
|
+
for i in range(4 if name == "XREADGROUP" else 1, len(args)):
|
|
370
|
+
if word(i) == "BLOCK":
|
|
371
|
+
return None
|
|
372
|
+
if word(i) == "STREAMS":
|
|
373
|
+
at = i
|
|
374
|
+
break
|
|
375
|
+
if at > 0:
|
|
376
|
+
key = at + 1
|
|
377
|
+
elif name not in _REDIS_NO_KEY and name not in _REDIS_PREFIXES:
|
|
378
|
+
key = 1
|
|
379
|
+
return command + " " + redis_key_shape(args[key]) if key is not None and key < len(args) else command
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def _redis_statement(text):
|
|
383
|
+
"""A statement a package sent: [PIPELINE|MULTI] COMMAND [SUBCOMMAND] [key].
|
|
384
|
+
Anything else (an argument a package let through) is (other)."""
|
|
385
|
+
words = _SPACE_RUN.split(text.strip(_SPACE))
|
|
386
|
+
if words[0] == "":
|
|
387
|
+
return ""
|
|
388
|
+
out = []
|
|
389
|
+
i = 0
|
|
390
|
+
if _upper(words[0]) in _REDIS_PREFIXES and len(words) > 1:
|
|
391
|
+
out.append(_upper(words[0]))
|
|
392
|
+
i = 1
|
|
393
|
+
if not _REDIS_WORD.match(words[i]):
|
|
394
|
+
return OTHER_STATEMENT
|
|
395
|
+
name = _upper(words[i])
|
|
396
|
+
i += 1
|
|
397
|
+
if name in _REDIS_WAITING:
|
|
398
|
+
return OTHER_STATEMENT
|
|
399
|
+
out.append(name)
|
|
400
|
+
if name in _REDIS_CONTAINERS and i < len(words) and _REDIS_WORD.match(words[i]):
|
|
401
|
+
out.append(_upper(words[i]))
|
|
402
|
+
i += 1
|
|
403
|
+
if i < len(words) - 1:
|
|
404
|
+
return OTHER_STATEMENT
|
|
405
|
+
if i < len(words):
|
|
406
|
+
out.append(redis_key_shape(words[i]))
|
|
407
|
+
return " ".join(out)
|
|
408
|
+
|
|
409
|
+
|
|
267
410
|
def normalize_statement(text, db):
|
|
268
411
|
"""The statement to store, or "" when nothing is left of it."""
|
|
269
412
|
if not isinstance(text, str):
|
|
@@ -273,6 +416,8 @@ def normalize_statement(text, db):
|
|
|
273
416
|
db = database_of(db)
|
|
274
417
|
if db == "mongodb":
|
|
275
418
|
shaped = _mongo_statement(text)
|
|
419
|
+
elif db == "redis":
|
|
420
|
+
shaped = _redis_statement(text)
|
|
276
421
|
elif db == "other" and "\\" in text:
|
|
277
422
|
# A database we don't know may or may not take a backslash as an
|
|
278
423
|
# escape, and each reading leaks what the other hides. When they
|
|
@@ -1047,12 +1192,279 @@ def _clickhouse_client(connection, root):
|
|
|
1047
1192
|
return None
|
|
1048
1193
|
|
|
1049
1194
|
|
|
1195
|
+
# ---- Redis clients: watch_database(client) (queries.md, "Redis") ----
|
|
1196
|
+
# Each command is one query under db "redis": its name and its first key's
|
|
1197
|
+
# shape (redis_command), never an argument. A pipeline or transaction is one,
|
|
1198
|
+
# timed as a whole: "PIPELINE " or "MULTI " and its first command. Blocking
|
|
1199
|
+
# commands (BLPOP, XREAD BLOCK) aren't timed. An error reply or no answer is
|
|
1200
|
+
# a failure; a nil reply isn't, and nor is a transaction a WATCH called off.
|
|
1201
|
+
#
|
|
1202
|
+
# The client itself is watched, not wrapped: its own execute_command (every
|
|
1203
|
+
# command goes through it) and pipeline() are replaced on that one object, so
|
|
1204
|
+
# it stays the client you made and nothing else is changed.
|
|
1205
|
+
|
|
1206
|
+
_REDIS_MARK = "_analytics_platform_watched"
|
|
1207
|
+
# What redis-py splits a command's first argument on ("CLIENT SETNAME").
|
|
1208
|
+
_ASCII_SPACE = re.compile(r"[ \t\n\r\x0b\x0c]+")
|
|
1209
|
+
# Not what names a batch: its framing, and a WATCH a cluster pipeline sends at once.
|
|
1210
|
+
_REDIS_FRAMING = frozenset(("MULTI", "EXEC", "DISCARD", "WATCH", "UNWATCH"))
|
|
1211
|
+
|
|
1212
|
+
|
|
1213
|
+
@contextmanager
|
|
1214
|
+
def own_redis():
|
|
1215
|
+
"""The package reading Redis for itself: a watched client leaves it out."""
|
|
1216
|
+
token = _own_redis.set(True)
|
|
1217
|
+
try:
|
|
1218
|
+
yield
|
|
1219
|
+
finally:
|
|
1220
|
+
_own_redis.reset(token)
|
|
1221
|
+
|
|
1222
|
+
|
|
1223
|
+
def _redis_arg(value):
|
|
1224
|
+
"""Bytes as text, as a key is read; a long value isn't a key worth
|
|
1225
|
+
decoding. Anything else as it is (a number makes "?")."""
|
|
1226
|
+
if isinstance(value, (bytes, bytearray, memoryview)):
|
|
1227
|
+
return bytes(value).decode("utf-8", "replace") if len(value) <= 1024 else None
|
|
1228
|
+
return value
|
|
1229
|
+
|
|
1230
|
+
|
|
1231
|
+
def _redis_list(args):
|
|
1232
|
+
"""The command as redis_command reads it: its first argument split into
|
|
1233
|
+
words, as redis-py does to send it, then the rest. The key is at most the
|
|
1234
|
+
third argument, so only the first few are taken, but all of XREAD's."""
|
|
1235
|
+
if not args:
|
|
1236
|
+
return None
|
|
1237
|
+
first = _redis_arg(args[0])
|
|
1238
|
+
head = [w for w in _ASCII_SPACE.split(first) if w] if isinstance(first, str) and _ASCII_SPACE.search(first) else [first]
|
|
1239
|
+
name = head[0] if head else ""
|
|
1240
|
+
end = len(args) if isinstance(name, str) and _upper(name) in ("XREAD", "XREADGROUP") else min(len(args), 5 - len(head))
|
|
1241
|
+
return head + [_redis_arg(args[i]) for i in range(1, end)]
|
|
1242
|
+
|
|
1243
|
+
|
|
1244
|
+
def _redis_start(args):
|
|
1245
|
+
"""The statement a command starting now is timed as, or None."""
|
|
1246
|
+
if _own_redis.get() or not _active():
|
|
1247
|
+
return None
|
|
1248
|
+
try:
|
|
1249
|
+
return redis_command(_redis_list(args))
|
|
1250
|
+
except Exception:
|
|
1251
|
+
return None
|
|
1252
|
+
|
|
1253
|
+
|
|
1254
|
+
def _redis_failed(error):
|
|
1255
|
+
# EXEC answering nil: the transaction a WATCH called off.
|
|
1256
|
+
return error is not None and type(error).__name__ != "WatchError"
|
|
1257
|
+
|
|
1258
|
+
|
|
1259
|
+
def _replies_failed(replies):
|
|
1260
|
+
"""A pipeline run with raise_on_error=False hands its errors back."""
|
|
1261
|
+
return isinstance(replies, (list, tuple)) and any(isinstance(r, Exception) and _redis_failed(r) for r in replies)
|
|
1262
|
+
|
|
1263
|
+
|
|
1264
|
+
def _redis_record(statement, started, error, replies=None):
|
|
1265
|
+
try:
|
|
1266
|
+
failed = _redis_failed(error) or _replies_failed(replies)
|
|
1267
|
+
record("redis", statement, (time.perf_counter() - started) * 1000, failed)
|
|
1268
|
+
except Exception:
|
|
1269
|
+
pass # never in place of the command's own result or error
|
|
1270
|
+
|
|
1271
|
+
|
|
1272
|
+
def _redis_timed(call, begin, batch=False):
|
|
1273
|
+
"""call (sync or async) timed as the statement begin(args) gives, or run
|
|
1274
|
+
untimed when that's None. A batch's replies can hold its error replies."""
|
|
1275
|
+
if inspect.iscoroutinefunction(call):
|
|
1276
|
+
async def timed_async(*args, **kwargs):
|
|
1277
|
+
statement = begin(args)
|
|
1278
|
+
if statement is None:
|
|
1279
|
+
return await call(*args, **kwargs)
|
|
1280
|
+
started, error, result = time.perf_counter(), None, None
|
|
1281
|
+
try:
|
|
1282
|
+
result = await call(*args, **kwargs)
|
|
1283
|
+
return result
|
|
1284
|
+
except BaseException as raised:
|
|
1285
|
+
error = raised
|
|
1286
|
+
raise
|
|
1287
|
+
finally:
|
|
1288
|
+
_redis_record(statement, started, error, result if batch else None)
|
|
1289
|
+
return timed_async
|
|
1290
|
+
|
|
1291
|
+
def timed(*args, **kwargs):
|
|
1292
|
+
statement = begin(args)
|
|
1293
|
+
if statement is None:
|
|
1294
|
+
return call(*args, **kwargs)
|
|
1295
|
+
started, error, result = time.perf_counter(), None, None
|
|
1296
|
+
try:
|
|
1297
|
+
result = call(*args, **kwargs)
|
|
1298
|
+
return result
|
|
1299
|
+
except BaseException as raised:
|
|
1300
|
+
error = raised
|
|
1301
|
+
raise
|
|
1302
|
+
finally:
|
|
1303
|
+
_redis_record(statement, started, error, result if batch else None)
|
|
1304
|
+
return timed
|
|
1305
|
+
|
|
1306
|
+
|
|
1307
|
+
def _timed_batch(pipe, call, batch):
|
|
1308
|
+
"""call, a pipeline's execute, timed as one call named by its first command."""
|
|
1309
|
+
def begin(_args):
|
|
1310
|
+
first, batch[0] = batch[0], None
|
|
1311
|
+
if first is None or _own_redis.get() or not _active():
|
|
1312
|
+
return None
|
|
1313
|
+
# transaction (sync), is_transaction (asyncio), _transaction (an asyncio
|
|
1314
|
+
# cluster's); a sync cluster's says so only by the strategy it runs.
|
|
1315
|
+
multi = any(getattr(pipe, name, False) is True
|
|
1316
|
+
for name in ("transaction", "is_transaction", "_transaction", "explicit_transaction")) \
|
|
1317
|
+
or type(getattr(pipe, "_execution_strategy", None)).__name__ == "TransactionStrategy"
|
|
1318
|
+
return ("MULTI " if multi else "PIPELINE ") + first
|
|
1319
|
+
|
|
1320
|
+
return _redis_timed(call, begin, batch=True)
|
|
1321
|
+
|
|
1322
|
+
|
|
1323
|
+
def _watch_redis_pipeline(pipe):
|
|
1324
|
+
"""A pipeline (or transaction) from a watched client: the commands queued
|
|
1325
|
+
name it, execute() times it. The ones a WATCH makes run at once (redis-py's
|
|
1326
|
+
immediate_execute_command) are timed on their own."""
|
|
1327
|
+
batch = [None]
|
|
1328
|
+
|
|
1329
|
+
def note(args):
|
|
1330
|
+
if batch[0] is None:
|
|
1331
|
+
statement = _redis_start(args)
|
|
1332
|
+
if statement and statement.split(" ", 1)[0] not in _REDIS_FRAMING:
|
|
1333
|
+
batch[0] = statement
|
|
1334
|
+
|
|
1335
|
+
immediate = getattr(pipe, "immediate_execute_command", None)
|
|
1336
|
+
staged = "pipeline_execute_command" if callable(immediate) and callable(getattr(pipe, "pipeline_execute_command", None)) \
|
|
1337
|
+
else "execute_command"
|
|
1338
|
+
queue = getattr(pipe, staged)
|
|
1339
|
+
|
|
1340
|
+
def queued(*args, **options):
|
|
1341
|
+
note(args)
|
|
1342
|
+
return queue(*args, **options)
|
|
1343
|
+
|
|
1344
|
+
setattr(pipe, staged, queued)
|
|
1345
|
+
if staged == "pipeline_execute_command":
|
|
1346
|
+
pipe.immediate_execute_command = _redis_timed(immediate, _redis_start)
|
|
1347
|
+
reset = getattr(pipe, "reset", None)
|
|
1348
|
+
if callable(reset):
|
|
1349
|
+
def reset_batch(*args, **kwargs):
|
|
1350
|
+
batch[0] = None
|
|
1351
|
+
return reset(*args, **kwargs)
|
|
1352
|
+
pipe.reset = reset_batch
|
|
1353
|
+
pipe.execute = _timed_batch(pipe, pipe.execute, batch)
|
|
1354
|
+
return pipe
|
|
1355
|
+
|
|
1356
|
+
|
|
1357
|
+
def _watch_redis(client):
|
|
1358
|
+
"""A redis-py client (sync or asyncio, Redis or RedisCluster), in place."""
|
|
1359
|
+
if getattr(client, _REDIS_MARK, False):
|
|
1360
|
+
return client
|
|
1361
|
+
client.execute_command = _redis_timed(client.execute_command, _redis_start)
|
|
1362
|
+
pipeline = getattr(client, "pipeline", None)
|
|
1363
|
+
if callable(pipeline):
|
|
1364
|
+
def watched_pipeline(*args, **kwargs):
|
|
1365
|
+
pipe = pipeline(*args, **kwargs)
|
|
1366
|
+
try:
|
|
1367
|
+
_watch_redis_pipeline(pipe)
|
|
1368
|
+
except Exception:
|
|
1369
|
+
pass # its commands still run, untimed
|
|
1370
|
+
return pipe
|
|
1371
|
+
client.pipeline = watched_pipeline
|
|
1372
|
+
setattr(client, _REDIS_MARK, True)
|
|
1373
|
+
return client
|
|
1374
|
+
|
|
1375
|
+
|
|
1376
|
+
def _is_redis_client(client):
|
|
1377
|
+
"""redis-py's (or valkey-py's) Redis or RedisCluster, or a subclass of one."""
|
|
1378
|
+
if not callable(getattr(client, "execute_command", None)) or not callable(getattr(client, "pipeline", None)):
|
|
1379
|
+
return False
|
|
1380
|
+
return any(klass.__module__.split(".")[0] in ("redis", "valkey") for klass in type(client).__mro__)
|
|
1381
|
+
|
|
1382
|
+
|
|
1383
|
+
def _making_watched(make):
|
|
1384
|
+
"""make, a function that makes (or connects) a Redis client, made to
|
|
1385
|
+
hand back each client watched."""
|
|
1386
|
+
def make_watched(*args, **kwargs):
|
|
1387
|
+
client = make(*args, **kwargs)
|
|
1388
|
+
# Inside the app's cache call: a client we can't watch (a custom
|
|
1389
|
+
# CLIENT_CLASS's) is handed back as it is, never an error.
|
|
1390
|
+
try:
|
|
1391
|
+
if _is_redis_client(client):
|
|
1392
|
+
_watch_redis(client)
|
|
1393
|
+
except Exception:
|
|
1394
|
+
pass
|
|
1395
|
+
return client
|
|
1396
|
+
setattr(make_watched, _REDIS_MARK, True)
|
|
1397
|
+
return make_watched
|
|
1398
|
+
|
|
1399
|
+
|
|
1400
|
+
def _watch_django_cache(cache):
|
|
1401
|
+
"""A Django cache on Redis: Django's own RedisCache makes a client for each
|
|
1402
|
+
operation, so the clients it makes are watched as it makes them;
|
|
1403
|
+
django-redis keeps its clients, and those it has and connects are."""
|
|
1404
|
+
module = type(cache).__module__
|
|
1405
|
+
if module.startswith("django.core.cache.backends.redis"):
|
|
1406
|
+
inner = cache._cache
|
|
1407
|
+
if not getattr(inner._client, _REDIS_MARK, False):
|
|
1408
|
+
inner._client = _making_watched(inner._client)
|
|
1409
|
+
return True
|
|
1410
|
+
if module.startswith("django_redis"):
|
|
1411
|
+
inner = cache.client
|
|
1412
|
+
connect = getattr(inner, "connect", None)
|
|
1413
|
+
if not callable(connect) or getattr(connect, _REDIS_MARK, False):
|
|
1414
|
+
return callable(connect)
|
|
1415
|
+
inner.connect = _making_watched(connect)
|
|
1416
|
+
held = list(getattr(inner, "_clients", None) or []) + list((getattr(inner, "_serverdict", None) or {}).values())
|
|
1417
|
+
for client in held:
|
|
1418
|
+
if client is not None and _is_redis_client(client):
|
|
1419
|
+
_watch_redis(client)
|
|
1420
|
+
return True
|
|
1421
|
+
return False
|
|
1422
|
+
|
|
1423
|
+
|
|
1424
|
+
def _watch_django_alias(cache):
|
|
1425
|
+
"""Django keeps a cache object per thread: the one watched is this
|
|
1426
|
+
thread's. The others of its alias are watched as Django makes them."""
|
|
1427
|
+
try:
|
|
1428
|
+
from django.core.cache import caches
|
|
1429
|
+
except Exception:
|
|
1430
|
+
return
|
|
1431
|
+
try:
|
|
1432
|
+
alias = next((name for name in caches if caches[name] is cache), None)
|
|
1433
|
+
if alias is None:
|
|
1434
|
+
return
|
|
1435
|
+
aliases = getattr(caches, _REDIS_MARK, None)
|
|
1436
|
+
if aliases is None:
|
|
1437
|
+
create = caches.create_connection
|
|
1438
|
+
|
|
1439
|
+
def create_watched(name, *args, **kwargs):
|
|
1440
|
+
made = create(name, *args, **kwargs)
|
|
1441
|
+
if name in aliases:
|
|
1442
|
+
try:
|
|
1443
|
+
_watch_django_cache(made)
|
|
1444
|
+
except Exception:
|
|
1445
|
+
pass # that thread's cache still works, untimed
|
|
1446
|
+
return made
|
|
1447
|
+
aliases = set()
|
|
1448
|
+
caches.create_connection = create_watched
|
|
1449
|
+
setattr(caches, _REDIS_MARK, aliases)
|
|
1450
|
+
aliases.add(alias)
|
|
1451
|
+
except Exception:
|
|
1452
|
+
pass # this thread's cache is still watched
|
|
1453
|
+
|
|
1454
|
+
|
|
1050
1455
|
def watch_database(connection, db=None):
|
|
1051
|
-
"""A DB-API connection,
|
|
1052
|
-
|
|
1053
|
-
|
|
1456
|
+
"""A DB-API connection, a clickhouse-connect / clickhouse-driver client,
|
|
1457
|
+
a redis-py client or a Django cache on Redis, whose queries are timed:
|
|
1458
|
+
use what it returns in place of the connection (a Redis client or cache
|
|
1459
|
+
comes back as itself, watched). db: one of DATABASES (default: by its driver)."""
|
|
1054
1460
|
if isinstance(connection, (_Connection, _ClickHouse)):
|
|
1055
1461
|
return connection
|
|
1462
|
+
if _is_redis_client(connection):
|
|
1463
|
+
return _watch_redis(connection)
|
|
1464
|
+
if type(connection).__module__.startswith(("django.core.cache.backends.redis", "django_redis")):
|
|
1465
|
+
if _watch_django_cache(connection):
|
|
1466
|
+
_watch_django_alias(connection)
|
|
1467
|
+
return connection
|
|
1056
1468
|
root = type(connection).__module__.split(".")[0]
|
|
1057
1469
|
client = _clickhouse_client(connection, root)
|
|
1058
1470
|
if client is not None:
|
|
@@ -23,6 +23,7 @@ import weakref
|
|
|
23
23
|
from datetime import datetime, timezone
|
|
24
24
|
|
|
25
25
|
from .core import Periodic
|
|
26
|
+
from .queries import own_redis
|
|
26
27
|
|
|
27
28
|
SAMPLE_EVERY = 60
|
|
28
29
|
MAX_QUEUES = 100
|
|
@@ -126,7 +127,10 @@ class Queues:
|
|
|
126
127
|
for entry in list(self.entries.values()):
|
|
127
128
|
gauges = None
|
|
128
129
|
try:
|
|
129
|
-
|
|
130
|
+
# Through the app's own connection (RQ's), maybe watched:
|
|
131
|
+
# our reads aren't its commands.
|
|
132
|
+
with own_redis():
|
|
133
|
+
gauges = entry.read()
|
|
130
134
|
except Exception as error:
|
|
131
135
|
self.core.log("performance", "could not read queue %s:" % entry.queue, error)
|
|
132
136
|
with self.lock:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: analytics-platform
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.6
|
|
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
|
|
@@ -317,6 +317,8 @@ read, so no data leaves it.
|
|
|
317
317
|
| pymongo | nothing, but **create the platform before your `MongoClient`**: pymongo only reports to clients made after the package is listening |
|
|
318
318
|
| Any other DB-API driver (psycopg, psycopg2, mysqlclient, PyMySQL, sqlite3...) | `conn = platform.watch_database(psycopg.connect(...))`, and use `conn` as before |
|
|
319
319
|
| ClickHouse: clickhouse-connect, clickhouse-driver | `client = platform.watch_database(clickhouse_connect.get_client(...))` (or a `clickhouse_driver.Client`), and use `client` as before |
|
|
320
|
+
| Redis: redis-py (`Redis`, `RedisCluster`, sync or `redis.asyncio`) | `platform.watch_database(r)`: the client is watched in place, so keep using `r` |
|
|
321
|
+
| Django's cache on Redis (Django's own `RedisCache`, or django-redis) | `platform.watch_database(caches["default"])`, once at start-up (in `AppConfig.ready()`) |
|
|
320
322
|
|
|
321
323
|
A query outside any request or job is counted as `JOB (background)`. Each
|
|
322
324
|
query is counted once: when SQLAlchemy or Django runs it over a watched
|
|
@@ -331,6 +333,23 @@ they answer. clickhouse-driver's `execute()` likewise. The streaming
|
|
|
331
333
|
queries (`query_rows_stream()`, `execute_iter()`) aren't timed. Only the
|
|
332
334
|
client you pass is wrapped; the library itself is left alone.
|
|
333
335
|
|
|
336
|
+
Redis: each command is counted by its name and the shape of its key (`GET
|
|
337
|
+
user:?:cart`); no other argument or value is read. A pipeline or transaction
|
|
338
|
+
is one call (`PIPELINE GET user:?`, `MULTI SET cart:?`), timed as a whole.
|
|
339
|
+
Commands that wait (`BLPOP`, `XREAD BLOCK`, subscribing) aren't timed, and
|
|
340
|
+
nor are this package's own reads of your RQ queues. An error reply counts
|
|
341
|
+
as failed; a nil reply doesn't.
|
|
342
|
+
|
|
343
|
+
```python
|
|
344
|
+
r = redis.Redis.from_url(REDIS_URL)
|
|
345
|
+
platform.watch_database(r)
|
|
346
|
+
|
|
347
|
+
# Django (settings: CACHES backend django.core.cache.backends.redis.RedisCache
|
|
348
|
+
# or django_redis.cache.RedisCache), in your AppConfig.ready():
|
|
349
|
+
from django.core.cache import caches
|
|
350
|
+
platform.watch_database(caches["default"])
|
|
351
|
+
```
|
|
352
|
+
|
|
334
353
|
## Outgoing calls
|
|
335
354
|
|
|
336
355
|
Health → Server speed → Calls shows how long each route waited on other
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "analytics-platform"
|
|
7
|
-
version = "0.3.
|
|
7
|
+
version = "0.3.6"
|
|
8
8
|
description = "Firewall, error monitoring and server analytics for your Python server, run from your analytics dashboard."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.8"
|
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
"""Redis command timings (sdk/protocols/queries.md, "Redis"): redis_command
|
|
2
|
+
against the shared cases, and the redis-py hooks (sync, asyncio, pipelines,
|
|
3
|
+
transactions, a Django cache) against stand-ins shaped as redis-py's objects
|
|
4
|
+
are; the package doesn't depend on redis-py.
|
|
5
|
+
|
|
6
|
+
python3 -m unittest discover -s tests
|
|
7
|
+
"""
|
|
8
|
+
import asyncio
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import sys
|
|
12
|
+
import threading
|
|
13
|
+
import types
|
|
14
|
+
import unittest
|
|
15
|
+
from unittest import mock
|
|
16
|
+
|
|
17
|
+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
|
18
|
+
|
|
19
|
+
from analytics_platform import create_platform # noqa: E402
|
|
20
|
+
from analytics_platform import queries as q # noqa: E402
|
|
21
|
+
|
|
22
|
+
CASES = os.path.join(os.path.dirname(__file__), "..", "..", "protocols", "query-cases.json")
|
|
23
|
+
SITE = "a1b2c3d4e5f6a1b2c3d4e5f6"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def make_platform(**options):
|
|
27
|
+
return create_platform(site_id=SITE, secret="s", endpoint="http://127.0.0.1:9",
|
|
28
|
+
firewall=False, analytics=False, flags=False, inventory=False, posture=False,
|
|
29
|
+
resources=False, proxy=False, maintenance=False, crawlers=False, forms=False,
|
|
30
|
+
audit=False, headers=False, errors=False, **options)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ResponseError(Exception):
|
|
34
|
+
pass
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class WatchError(Exception):
|
|
38
|
+
pass
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def reply(args):
|
|
42
|
+
"""What the stand-in server answers: nil for "missing", an error reply for "text"."""
|
|
43
|
+
key = args[1] if len(args) > 1 else None
|
|
44
|
+
if isinstance(key, bytes):
|
|
45
|
+
key = key.decode()
|
|
46
|
+
if key == "missing":
|
|
47
|
+
return None
|
|
48
|
+
if key == "text":
|
|
49
|
+
raise ResponseError("WRONGTYPE Operation against a key holding the wrong kind of value")
|
|
50
|
+
return b"v"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# ---- redis-py's shapes: every command through the client's execute_command;
|
|
54
|
+
# a Pipeline (a Redis subclass) stages commands, runs WATCH and what follows
|
|
55
|
+
# it at once, and sends the rest on execute() ----
|
|
56
|
+
|
|
57
|
+
class Redis:
|
|
58
|
+
def __init__(self):
|
|
59
|
+
self.sent = []
|
|
60
|
+
|
|
61
|
+
def execute_command(self, *args, **options):
|
|
62
|
+
self.sent.append(args)
|
|
63
|
+
return reply(args)
|
|
64
|
+
|
|
65
|
+
def get(self, name):
|
|
66
|
+
return self.execute_command("GET", name)
|
|
67
|
+
|
|
68
|
+
def incr(self, name):
|
|
69
|
+
return self.execute_command("INCR", name)
|
|
70
|
+
|
|
71
|
+
def client_setname(self, name):
|
|
72
|
+
return self.execute_command("CLIENT SETNAME", name)
|
|
73
|
+
|
|
74
|
+
def blpop(self, keys, timeout=0):
|
|
75
|
+
return self.execute_command("BLPOP", *keys, timeout)
|
|
76
|
+
|
|
77
|
+
def eval(self, script, numkeys, *keys_and_args):
|
|
78
|
+
return self.execute_command("EVAL", script, numkeys, *keys_and_args)
|
|
79
|
+
|
|
80
|
+
def pipeline(self, transaction=True, shard_hint=None):
|
|
81
|
+
return Pipeline(self, transaction)
|
|
82
|
+
|
|
83
|
+
def __enter__(self):
|
|
84
|
+
return self
|
|
85
|
+
|
|
86
|
+
def __exit__(self, *exc):
|
|
87
|
+
return None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class Pipeline(Redis):
|
|
91
|
+
def __init__(self, redis, transaction):
|
|
92
|
+
super().__init__()
|
|
93
|
+
self.redis = redis
|
|
94
|
+
self.transaction = transaction
|
|
95
|
+
self.stack = []
|
|
96
|
+
self.watching = False
|
|
97
|
+
self.explicit_transaction = False
|
|
98
|
+
|
|
99
|
+
def execute_command(self, *args, **kwargs):
|
|
100
|
+
if (self.watching or args[0] == "WATCH") and not self.explicit_transaction:
|
|
101
|
+
return self.immediate_execute_command(*args, **kwargs)
|
|
102
|
+
return self.pipeline_execute_command(*args, **kwargs)
|
|
103
|
+
|
|
104
|
+
def immediate_execute_command(self, *args, **options):
|
|
105
|
+
if args[0] == "WATCH":
|
|
106
|
+
self.watching = True
|
|
107
|
+
return True
|
|
108
|
+
return reply(args)
|
|
109
|
+
|
|
110
|
+
def pipeline_execute_command(self, *args, **options):
|
|
111
|
+
self.stack.append(args)
|
|
112
|
+
return self
|
|
113
|
+
|
|
114
|
+
def watch(self, *names):
|
|
115
|
+
return self.execute_command("WATCH", *names)
|
|
116
|
+
|
|
117
|
+
def multi(self):
|
|
118
|
+
self.explicit_transaction = True
|
|
119
|
+
|
|
120
|
+
def reset(self):
|
|
121
|
+
self.stack, self.watching, self.explicit_transaction = [], False, False
|
|
122
|
+
|
|
123
|
+
def execute(self, raise_on_error=True):
|
|
124
|
+
try:
|
|
125
|
+
if any(args[1:2] == ("changed",) for args in self.stack):
|
|
126
|
+
raise WatchError("Watched variable changed.")
|
|
127
|
+
replies = []
|
|
128
|
+
for args in self.stack:
|
|
129
|
+
try:
|
|
130
|
+
replies.append(reply(args))
|
|
131
|
+
except ResponseError as error:
|
|
132
|
+
if raise_on_error:
|
|
133
|
+
raise
|
|
134
|
+
replies.append(error)
|
|
135
|
+
return replies
|
|
136
|
+
finally:
|
|
137
|
+
Pipeline.reset(self)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class AsyncRedis(Redis):
|
|
141
|
+
async def execute_command(self, *args, **options):
|
|
142
|
+
await asyncio.sleep(0)
|
|
143
|
+
return reply(args)
|
|
144
|
+
|
|
145
|
+
def pipeline(self, transaction=True, shard_hint=None):
|
|
146
|
+
return AsyncPipeline(self, transaction)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class AsyncPipeline(Pipeline):
|
|
150
|
+
async def immediate_execute_command(self, *args, **options):
|
|
151
|
+
return Pipeline.immediate_execute_command(self, *args, **options)
|
|
152
|
+
|
|
153
|
+
async def execute(self, raise_on_error=True):
|
|
154
|
+
await asyncio.sleep(0)
|
|
155
|
+
return Pipeline.execute(self, raise_on_error)
|
|
156
|
+
|
|
157
|
+
async def reset(self):
|
|
158
|
+
Pipeline.reset(self)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
# Detected by their module, as redis-py's own are.
|
|
162
|
+
for _class in (Redis, Pipeline, AsyncRedis, AsyncPipeline):
|
|
163
|
+
_class.__module__ = "redis.client"
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
class SharedCases(unittest.TestCase):
|
|
167
|
+
def test_every_shared_redis_command_case_comes_out_character_for_character(self):
|
|
168
|
+
with open(CASES, encoding="utf-8") as handle:
|
|
169
|
+
cases = json.load(handle)
|
|
170
|
+
self.assertGreater(len(cases["redis_commands"]), 20)
|
|
171
|
+
for case in cases["redis_commands"]:
|
|
172
|
+
self.assertEqual(q.redis_command(case["args"]), case["expected"], repr(case["args"]))
|
|
173
|
+
|
|
174
|
+
def test_unicode_letters_are_not_ascii_letters(self):
|
|
175
|
+
self.assertIsNone(q.redis_command(["ſet", "k"]))
|
|
176
|
+
self.assertEqual(q.redis_command(["SET", "straße:1"]), "SET ?:?")
|
|
177
|
+
self.assertEqual(q.normalize_statement("xread k", "redis"), "XREAD k")
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
class Hooks(unittest.TestCase):
|
|
181
|
+
def setUp(self):
|
|
182
|
+
self.platform = make_platform()
|
|
183
|
+
self.queries = self.platform._modules["performance"].queries
|
|
184
|
+
self.queries.rows.clear()
|
|
185
|
+
self.queries.per_minute.clear()
|
|
186
|
+
|
|
187
|
+
def tearDown(self):
|
|
188
|
+
self.queries.rows.clear()
|
|
189
|
+
self.queries.retry = []
|
|
190
|
+
self.platform.close()
|
|
191
|
+
|
|
192
|
+
def redis_rows(self):
|
|
193
|
+
return {key[4]: (row["count"], row["errors"]) for key, row in self.queries.rows.items() if key[3] == "redis"}
|
|
194
|
+
|
|
195
|
+
def test_each_command_is_timed_with_errors_counted_nil_not_and_blocking_ones_left_out(self):
|
|
196
|
+
original = Redis.__dict__["execute_command"]
|
|
197
|
+
redis = Redis()
|
|
198
|
+
self.assertIs(self.platform.watch_database(redis), redis)
|
|
199
|
+
self.assertIs(self.platform.watch_database(redis), redis)
|
|
200
|
+
self.assertEqual(redis.get("user:42:cart"), b"v")
|
|
201
|
+
self.assertEqual(redis.get(b"user:7:cart"), b"v")
|
|
202
|
+
self.assertIsNone(redis.get("missing"))
|
|
203
|
+
with self.assertRaises(ResponseError):
|
|
204
|
+
redis.incr("text")
|
|
205
|
+
redis.client_setname("web-1")
|
|
206
|
+
redis.eval("return 1", 1, "lock:order:9", "hunter2")
|
|
207
|
+
redis.blpop(["jobs"], 0)
|
|
208
|
+
self.assertEqual(self.redis_rows(), {
|
|
209
|
+
"GET user:?:cart": (2, 0), "GET missing": (1, 0), "INCR text": (1, 1),
|
|
210
|
+
"CLIENT SETNAME": (1, 0), "EVAL lock:order:?": (1, 0)})
|
|
211
|
+
self.assertIs(Redis.__dict__["execute_command"], original, "the class is left alone")
|
|
212
|
+
|
|
213
|
+
def test_a_pipeline_or_transaction_is_one_call_named_by_its_first_command(self):
|
|
214
|
+
redis = self.platform.watch_database(Redis())
|
|
215
|
+
pipe = redis.pipeline(transaction=False)
|
|
216
|
+
pipe.get("user:1")
|
|
217
|
+
pipe.incr("user:2")
|
|
218
|
+
self.assertEqual(pipe.execute(), [b"v", b"v"])
|
|
219
|
+
with redis.pipeline() as tx:
|
|
220
|
+
tx.execute_command("SET", "cart:9", "x")
|
|
221
|
+
tx.get("cart:9")
|
|
222
|
+
tx.execute()
|
|
223
|
+
broken = redis.pipeline(transaction=False)
|
|
224
|
+
broken.get("user:3")
|
|
225
|
+
broken.incr("text")
|
|
226
|
+
self.assertIsInstance(broken.execute(raise_on_error=False)[1], ResponseError)
|
|
227
|
+
# An empty one isn't a call.
|
|
228
|
+
redis.pipeline().execute()
|
|
229
|
+
self.assertEqual(self.redis_rows(), {"PIPELINE GET user:?": (2, 1), "MULTI SET cart:?": (1, 0)})
|
|
230
|
+
|
|
231
|
+
def test_a_watch_runs_its_commands_at_once_and_a_called_off_transaction_isnt_a_failure(self):
|
|
232
|
+
redis = self.platform.watch_database(Redis())
|
|
233
|
+
pipe = redis.pipeline()
|
|
234
|
+
pipe.watch("stock:9")
|
|
235
|
+
pipe.get("stock:9")
|
|
236
|
+
pipe.multi()
|
|
237
|
+
pipe.execute_command("SET", "changed", "1")
|
|
238
|
+
with self.assertRaises(WatchError):
|
|
239
|
+
pipe.execute()
|
|
240
|
+
self.assertEqual(self.redis_rows(), {"WATCH stock:?": (1, 0), "GET stock:?": (1, 0), "MULTI SET changed": (1, 0)})
|
|
241
|
+
|
|
242
|
+
def test_asyncio_clients_and_pipelines(self):
|
|
243
|
+
redis = self.platform.watch_database(AsyncRedis())
|
|
244
|
+
|
|
245
|
+
async def run():
|
|
246
|
+
self.assertEqual(await redis.get("user:42"), b"v")
|
|
247
|
+
with self.assertRaises(ResponseError):
|
|
248
|
+
await redis.incr("text")
|
|
249
|
+
pipe = redis.pipeline(transaction=False)
|
|
250
|
+
pipe.get("user:1")
|
|
251
|
+
pipe.get("user:2")
|
|
252
|
+
self.assertEqual(await pipe.execute(), [b"v", b"v"])
|
|
253
|
+
|
|
254
|
+
asyncio.run(run())
|
|
255
|
+
self.assertEqual(self.redis_rows(), {"GET user:?": (1, 0), "INCR text": (1, 1), "PIPELINE GET user:?": (1, 0)})
|
|
256
|
+
|
|
257
|
+
def test_the_packages_own_queue_reads_on_the_same_client_arent_timed(self):
|
|
258
|
+
redis = self.platform.watch_database(Redis())
|
|
259
|
+
queues = self.platform._modules["performance"].queues
|
|
260
|
+
|
|
261
|
+
def read():
|
|
262
|
+
pipe = redis.pipeline(transaction=False)
|
|
263
|
+
pipe.execute_command("LLEN", "rq:queue:default")
|
|
264
|
+
pipe.execute()
|
|
265
|
+
redis.execute_command("HGET", "rq:job:1", "enqueued_at")
|
|
266
|
+
return {"waiting": 0}
|
|
267
|
+
|
|
268
|
+
queues.entry("rq", "localhost:6379/0", "default", read)
|
|
269
|
+
with mock.patch.object(queues.core, "send"):
|
|
270
|
+
queues.sample()
|
|
271
|
+
redis.get("user:1")
|
|
272
|
+
self.assertEqual(self.redis_rows(), {"GET user:?": (1, 0)})
|
|
273
|
+
|
|
274
|
+
def test_djangos_redis_cache_watches_each_client_it_makes(self):
|
|
275
|
+
class RedisCacheClient:
|
|
276
|
+
def __init__(self):
|
|
277
|
+
self._client = Redis
|
|
278
|
+
|
|
279
|
+
def get_client(self, key=None, *, write=False):
|
|
280
|
+
return self._client()
|
|
281
|
+
|
|
282
|
+
class RedisCache:
|
|
283
|
+
def __init__(self):
|
|
284
|
+
self._cache = RedisCacheClient()
|
|
285
|
+
|
|
286
|
+
def get(self, key):
|
|
287
|
+
return self._cache.get_client(key).get(":1:" + key)
|
|
288
|
+
|
|
289
|
+
RedisCache.__module__ = "django.core.cache.backends.redis"
|
|
290
|
+
cache = RedisCache()
|
|
291
|
+
self.assertIs(self.platform.watch_database(cache), cache)
|
|
292
|
+
self.platform.watch_database(cache)
|
|
293
|
+
cache.get("views")
|
|
294
|
+
cache.get("views")
|
|
295
|
+
self.assertEqual(self.redis_rows(), {"GET :?:views": (2, 0)})
|
|
296
|
+
|
|
297
|
+
def test_a_django_cache_made_for_another_thread_is_watched_too(self):
|
|
298
|
+
class RedisCacheClient:
|
|
299
|
+
def get_client(self, key=None, *, write=False):
|
|
300
|
+
return self._client()
|
|
301
|
+
|
|
302
|
+
class RedisCache:
|
|
303
|
+
def __init__(self):
|
|
304
|
+
self._cache = RedisCacheClient()
|
|
305
|
+
self._cache._client = Redis
|
|
306
|
+
|
|
307
|
+
def get(self, key):
|
|
308
|
+
return self._cache.get_client(key).get(":1:" + key)
|
|
309
|
+
|
|
310
|
+
RedisCache.__module__ = "django.core.cache.backends.redis"
|
|
311
|
+
|
|
312
|
+
class CacheHandler:
|
|
313
|
+
"""As Django's caches: one cache object per alias per thread."""
|
|
314
|
+
def __init__(self):
|
|
315
|
+
self.local = threading.local()
|
|
316
|
+
|
|
317
|
+
def __iter__(self):
|
|
318
|
+
return iter(["default", "other"])
|
|
319
|
+
|
|
320
|
+
def create_connection(self, alias):
|
|
321
|
+
return RedisCache()
|
|
322
|
+
|
|
323
|
+
def __getitem__(self, alias):
|
|
324
|
+
made = getattr(self.local, alias, None)
|
|
325
|
+
if made is None:
|
|
326
|
+
made = self.create_connection(alias)
|
|
327
|
+
setattr(self.local, alias, made)
|
|
328
|
+
return made
|
|
329
|
+
|
|
330
|
+
caches = CacheHandler()
|
|
331
|
+
module = types.ModuleType("django.core.cache")
|
|
332
|
+
module.caches = caches
|
|
333
|
+
with mock.patch.dict(sys.modules, {"django": types.ModuleType("django"), "django.core": types.ModuleType("django.core"),
|
|
334
|
+
"django.core.cache": module}):
|
|
335
|
+
self.platform.watch_database(caches["default"])
|
|
336
|
+
self.platform.watch_database(caches["default"])
|
|
337
|
+
caches["default"].get("views")
|
|
338
|
+
thread = threading.Thread(target=lambda: (caches["default"].get("views"), caches["other"].get("views")))
|
|
339
|
+
thread.start()
|
|
340
|
+
thread.join()
|
|
341
|
+
self.assertEqual(self.redis_rows(), {"GET :?:views": (2, 0)})
|
|
342
|
+
|
|
343
|
+
def test_a_sync_cluster_transaction_is_multi_and_its_watch_doesnt_name_it(self):
|
|
344
|
+
class TransactionStrategy:
|
|
345
|
+
pass
|
|
346
|
+
|
|
347
|
+
class ClusterPipeline(Redis):
|
|
348
|
+
def __init__(self):
|
|
349
|
+
super().__init__()
|
|
350
|
+
self._execution_strategy = TransactionStrategy()
|
|
351
|
+
self.stack = []
|
|
352
|
+
|
|
353
|
+
def execute_command(self, *args, **kwargs):
|
|
354
|
+
if args[0] != "WATCH":
|
|
355
|
+
self.stack.append(args)
|
|
356
|
+
return self
|
|
357
|
+
|
|
358
|
+
def execute(self, raise_on_error=True):
|
|
359
|
+
stack, self.stack = self.stack, []
|
|
360
|
+
return [reply(args) for args in stack]
|
|
361
|
+
|
|
362
|
+
class RedisCluster(Redis):
|
|
363
|
+
def pipeline(self, transaction=None, shard_hint=None):
|
|
364
|
+
return ClusterPipeline()
|
|
365
|
+
|
|
366
|
+
RedisCluster.__module__ = ClusterPipeline.__module__ = "redis.cluster"
|
|
367
|
+
redis = self.platform.watch_database(RedisCluster())
|
|
368
|
+
pipe = redis.pipeline(transaction=True)
|
|
369
|
+
pipe.execute_command("WATCH", "stock:9")
|
|
370
|
+
pipe.get("stock:9")
|
|
371
|
+
self.assertEqual(pipe.execute(), [b"v"])
|
|
372
|
+
self.assertEqual(self.redis_rows(), {"MULTI GET stock:?": (1, 0)})
|
|
373
|
+
|
|
374
|
+
def test_django_redis_watches_the_clients_it_holds_and_connects(self):
|
|
375
|
+
held = Redis()
|
|
376
|
+
|
|
377
|
+
class DefaultClient:
|
|
378
|
+
def __init__(self):
|
|
379
|
+
self._clients = [held, None]
|
|
380
|
+
|
|
381
|
+
def connect(self, index=0):
|
|
382
|
+
return Redis()
|
|
383
|
+
|
|
384
|
+
def get_client(self, write=True):
|
|
385
|
+
if self._clients[1] is None:
|
|
386
|
+
self._clients[1] = self.connect(1)
|
|
387
|
+
return self._clients[0 if write else 1]
|
|
388
|
+
|
|
389
|
+
class RedisCache:
|
|
390
|
+
client = DefaultClient()
|
|
391
|
+
|
|
392
|
+
RedisCache.__module__ = "django_redis.cache"
|
|
393
|
+
cache = RedisCache()
|
|
394
|
+
self.platform.watch_database(cache)
|
|
395
|
+
cache.client.get_client(write=True).get("session:a1")
|
|
396
|
+
cache.client.get_client(write=False).get("session:b2")
|
|
397
|
+
self.assertEqual(self.redis_rows(), {"GET session:?": (2, 0)})
|
|
398
|
+
|
|
399
|
+
def test_a_django_redis_client_that_isnt_redis_pys_is_handed_back_untouched(self):
|
|
400
|
+
class Custom:
|
|
401
|
+
"""A CLIENT_CLASS's connection: no execute_command to watch."""
|
|
402
|
+
__slots__ = ()
|
|
403
|
+
|
|
404
|
+
class DefaultClient:
|
|
405
|
+
_clients = [None]
|
|
406
|
+
|
|
407
|
+
def connect(self, index=0):
|
|
408
|
+
return Custom()
|
|
409
|
+
|
|
410
|
+
class RedisCache:
|
|
411
|
+
client = DefaultClient()
|
|
412
|
+
|
|
413
|
+
RedisCache.__module__ = "django_redis.cache"
|
|
414
|
+
cache = RedisCache()
|
|
415
|
+
self.platform.watch_database(cache)
|
|
416
|
+
self.assertIsInstance(cache.client.connect(0), Custom)
|
|
417
|
+
|
|
418
|
+
def test_with_server_speed_off_nothing_is_timed(self):
|
|
419
|
+
platform = make_platform(performance=False)
|
|
420
|
+
redis = Redis()
|
|
421
|
+
self.assertIs(platform.watch_database(redis), redis)
|
|
422
|
+
redis.get("user:1")
|
|
423
|
+
platform.close()
|
|
424
|
+
self.assertEqual(self.redis_rows(), {})
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
if __name__ == "__main__":
|
|
428
|
+
unittest.main()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{analytics_platform-0.3.5 → analytics_platform-0.3.6}/analytics_platform.egg-info/top_level.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|