analytics-platform 0.2.1__py3-none-any.whl
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/__init__.py +483 -0
- analytics_platform/analytics.py +101 -0
- analytics_platform/audit.py +53 -0
- analytics_platform/core.py +199 -0
- analytics_platform/crawlers.py +191 -0
- analytics_platform/django.py +112 -0
- analytics_platform/errors.py +113 -0
- analytics_platform/firewall.py +315 -0
- analytics_platform/flags.py +46 -0
- analytics_platform/forms.py +58 -0
- analytics_platform/inventory.py +72 -0
- analytics_platform/maintenance.py +61 -0
- analytics_platform/performance.py +98 -0
- analytics_platform/posture.py +264 -0
- analytics_platform/proxy.py +170 -0
- analytics_platform/request_rules.py +175 -0
- analytics_platform/resources.py +471 -0
- analytics_platform-0.2.1.dist-info/METADATA +227 -0
- analytics_platform-0.2.1.dist-info/RECORD +22 -0
- analytics_platform-0.2.1.dist-info/WHEEL +5 -0
- analytics_platform-0.2.1.dist-info/licenses/LICENSE +21 -0
- analytics_platform-0.2.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,471 @@
|
|
|
1
|
+
"""How the machine is doing (protocol in sdk/protocols/resources.md).
|
|
2
|
+
|
|
3
|
+
CPU, load, memory, swap and disks, sampled every minute and sent to
|
|
4
|
+
POST /events/resources every few minutes. The companion of the performance
|
|
5
|
+
module: that says how the requests went, this says whether the box can still
|
|
6
|
+
serve them, so the dashboard shows what you would otherwise run node_exporter,
|
|
7
|
+
Prometheus and Grafana for.
|
|
8
|
+
|
|
9
|
+
Nothing is guessed. A reading this machine won't give us is left out of the
|
|
10
|
+
row, never sent as a zero: the collector stores it as NULL and the dashboard
|
|
11
|
+
shows "-", because "0% CPU" and "we couldn't measure CPU" are different
|
|
12
|
+
statements and only one of them means healthy.
|
|
13
|
+
|
|
14
|
+
Standard library only, like the rest of the package: no psutil.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import os
|
|
18
|
+
import re
|
|
19
|
+
import socket
|
|
20
|
+
import threading
|
|
21
|
+
from datetime import datetime, timezone
|
|
22
|
+
|
|
23
|
+
from .core import Periodic
|
|
24
|
+
|
|
25
|
+
# Keep in step with the collector (expresscollect/Analytics/resources.py) and
|
|
26
|
+
# the agent (sdk/vps-agent).
|
|
27
|
+
SAMPLE_EVERY = 60
|
|
28
|
+
EVERY = 5 * 60
|
|
29
|
+
RETRY_EVERY = 60
|
|
30
|
+
OFF_EVERY = 600
|
|
31
|
+
# About 8 hours of samples: enough to ride out a collector outage, bounded so
|
|
32
|
+
# a long one can't grow in memory.
|
|
33
|
+
MAX_ROWS = 500
|
|
34
|
+
MAX_DISKS = 20
|
|
35
|
+
# Nobody watches a sub-gigabyte partition fill up; this drops the EFI
|
|
36
|
+
# partition and other small mounts without needing a list of paths.
|
|
37
|
+
MIN_DISK_MB = 1024
|
|
38
|
+
# Below this, two CPU readings are too close together to divide: the counters
|
|
39
|
+
# move in ticks, so a percentage over a few milliseconds is quantisation noise
|
|
40
|
+
# (0% or 100%, nothing between).
|
|
41
|
+
MIN_CPU_SECONDS = 1.0
|
|
42
|
+
# Real on-disk filesystems only: no tmpfs, devtmpfs, proc, sysfs, cgroup or
|
|
43
|
+
# squashfs (snaps), none of which are the customer's disk. overlay is here so
|
|
44
|
+
# a containerised app still reports something sensible.
|
|
45
|
+
FSTYPES = {"ext2", "ext3", "ext4", "xfs", "btrfs", "zfs", "f2fs", "jfs",
|
|
46
|
+
"reiserfs", "ufs", "vfat", "exfat", "ntfs", "ntfs3", "overlay", "apfs", "hfs"}
|
|
47
|
+
|
|
48
|
+
MB = 1024 * 1024
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def read_text(path, limit=65536):
|
|
52
|
+
try:
|
|
53
|
+
with open(path, "rb") as handle:
|
|
54
|
+
return handle.read(limit).decode("utf-8", "replace")
|
|
55
|
+
except OSError:
|
|
56
|
+
return ""
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# Sectors in /proc/diskstats are always 512 bytes: the unit of that interface,
|
|
60
|
+
# not of the drive.
|
|
61
|
+
IO_SECTOR = 512
|
|
62
|
+
# Two readings further apart than this say nothing about this minute. It is the
|
|
63
|
+
# rule that matters most here: this client only samples while a request is
|
|
64
|
+
# being served, so the pair can be an hour apart on a quiet site.
|
|
65
|
+
IO_MAX_PAIR_SECONDS = 300
|
|
66
|
+
# Not disks: loop and ram devices, optical drives, device-mapper and md, each
|
|
67
|
+
# of which would count what is underneath it a second time.
|
|
68
|
+
IO_NOT_A_DISK = re.compile(r"^(?:loop|ram|zram|sr|fd|dm-|md\d)")
|
|
69
|
+
# Partitions, which must not be counted alongside their disk.
|
|
70
|
+
IO_PARTITION = re.compile(r"^(?:(?:sd|vd|xvd|hd)[a-z]+\d+|(?:nvme\d+n\d+|mmcblk\d+)p\d+)$")
|
|
71
|
+
# Loopback and the virtual ends of container and VM networking.
|
|
72
|
+
NET_NOT_REAL = re.compile(r"^(?:lo|veth|docker|br-|virbr|tap|tun|vnet)")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def cpu_fields(text):
|
|
76
|
+
"""The jiffie counters from /proc/stat's first line, or None.
|
|
77
|
+
|
|
78
|
+
In order: user, nice, system, idle, iowait, irq, softirq, steal."""
|
|
79
|
+
for line in text.splitlines():
|
|
80
|
+
if not line.startswith("cpu "):
|
|
81
|
+
continue
|
|
82
|
+
try:
|
|
83
|
+
fields = [int(value) for value in line.split()[1:]]
|
|
84
|
+
except ValueError:
|
|
85
|
+
return None
|
|
86
|
+
return fields if len(fields) >= 4 else None
|
|
87
|
+
return None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def cpu_times_of(fields):
|
|
91
|
+
"""(busy, total) jiffies from the counters, for cpu_percent."""
|
|
92
|
+
total = sum(fields)
|
|
93
|
+
idle = fields[3] + (fields[4] if len(fields) > 4 else 0)
|
|
94
|
+
return total - idle, total
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def cpu_times(text):
|
|
98
|
+
"""(busy, total) jiffies from /proc/stat's first line, or None.
|
|
99
|
+
|
|
100
|
+
idle and iowait are the machine doing nothing; everything else, steal
|
|
101
|
+
included, is time it wasn't available to us."""
|
|
102
|
+
fields = cpu_fields(text)
|
|
103
|
+
return None if fields is None else cpu_times_of(fields)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
# user folds nice in, as top adds them together. Each is a share of the whole
|
|
107
|
+
# interval, so with idle and the interrupt buckets they come to 100.
|
|
108
|
+
CPU_SHARES = (
|
|
109
|
+
("cpu_user_pct", (0, 1)),
|
|
110
|
+
("cpu_system_pct", (2,)),
|
|
111
|
+
("cpu_iowait_pct", (4,)),
|
|
112
|
+
("cpu_steal_pct", (7,)),
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def cpu_shares(before, after):
|
|
117
|
+
"""Where the machine's time went between two /proc/stat readings.
|
|
118
|
+
|
|
119
|
+
Empty when the pair can't say, and a column the kernel is too old to carry
|
|
120
|
+
is left out rather than sent as a zero. steal is the one worth surfacing:
|
|
121
|
+
time the hypervisor gave to another tenant, which no tuning of the app
|
|
122
|
+
will change."""
|
|
123
|
+
if not before or not after:
|
|
124
|
+
return {}
|
|
125
|
+
total = sum(after) - sum(before)
|
|
126
|
+
if total <= 0:
|
|
127
|
+
return {}
|
|
128
|
+
out = {}
|
|
129
|
+
for name, columns in CPU_SHARES:
|
|
130
|
+
if any(i >= len(before) or i >= len(after) for i in columns):
|
|
131
|
+
continue
|
|
132
|
+
moved = sum(after[i] - before[i] for i in columns)
|
|
133
|
+
if moved < 0:
|
|
134
|
+
return {}
|
|
135
|
+
out[name] = round(min(100.0, 100.0 * moved / total), 1)
|
|
136
|
+
return out
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def disk_counters(text):
|
|
140
|
+
"""(bytes read, bytes written) since boot over the real disks, or None."""
|
|
141
|
+
read = written = 0
|
|
142
|
+
found = False
|
|
143
|
+
for line in text.splitlines():
|
|
144
|
+
fields = line.split()
|
|
145
|
+
if len(fields) < 10:
|
|
146
|
+
continue
|
|
147
|
+
name = fields[2]
|
|
148
|
+
if IO_NOT_A_DISK.match(name) or IO_PARTITION.match(name):
|
|
149
|
+
continue
|
|
150
|
+
try:
|
|
151
|
+
read += int(fields[5]) * IO_SECTOR
|
|
152
|
+
written += int(fields[9]) * IO_SECTOR
|
|
153
|
+
except ValueError:
|
|
154
|
+
continue
|
|
155
|
+
found = True
|
|
156
|
+
return (read, written) if found else None
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def net_counters(text):
|
|
160
|
+
"""(bytes in, bytes out) since boot over the real interfaces, or None."""
|
|
161
|
+
rx = tx = 0
|
|
162
|
+
found = False
|
|
163
|
+
for line in text.splitlines():
|
|
164
|
+
if ":" not in line:
|
|
165
|
+
continue
|
|
166
|
+
name, rest = line.split(":", 1)
|
|
167
|
+
name = name.strip()
|
|
168
|
+
if not name or NET_NOT_REAL.match(name):
|
|
169
|
+
continue
|
|
170
|
+
fields = rest.split()
|
|
171
|
+
if len(fields) < 9:
|
|
172
|
+
continue
|
|
173
|
+
try:
|
|
174
|
+
rx += int(fields[0])
|
|
175
|
+
tx += int(fields[8])
|
|
176
|
+
except ValueError:
|
|
177
|
+
continue
|
|
178
|
+
found = True
|
|
179
|
+
return (rx, tx) if found else None
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def rate_mbs(before, after, seconds):
|
|
183
|
+
"""Megabytes a second between two byte counters, or None when the pair
|
|
184
|
+
can't say: no pair, a reboot that reset them, no time, or too long a gap
|
|
185
|
+
to call it a rate for this minute."""
|
|
186
|
+
if before is None or after is None:
|
|
187
|
+
return None
|
|
188
|
+
if seconds <= 0 or seconds > IO_MAX_PAIR_SECONDS:
|
|
189
|
+
return None
|
|
190
|
+
moved = after - before
|
|
191
|
+
if moved < 0:
|
|
192
|
+
return None
|
|
193
|
+
return round(moved / seconds / MB, 1)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def cpu_percent(before, after):
|
|
197
|
+
"""How busy the machine was between two readings, or None when the pair
|
|
198
|
+
can't say: the first sample, a counter reset, or no time passed."""
|
|
199
|
+
if not before or not after:
|
|
200
|
+
return None
|
|
201
|
+
busy, total = after[0] - before[0], after[1] - before[1]
|
|
202
|
+
if total <= 0 or busy < 0:
|
|
203
|
+
return None
|
|
204
|
+
return round(min(100.0, 100.0 * busy / total), 1)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def memory_mb(text):
|
|
208
|
+
"""MemTotal/MemAvailable/Swap from /proc/meminfo, in whole megabytes.
|
|
209
|
+
|
|
210
|
+
Used is total - MemAvailable, never total - MemFree: page cache is not
|
|
211
|
+
pressure, and counting it as used makes every healthy Linux box look full.
|
|
212
|
+
A field the kernel didn't give us is left out of the result."""
|
|
213
|
+
values = {}
|
|
214
|
+
for line in text.splitlines():
|
|
215
|
+
name, _, rest = line.partition(":")
|
|
216
|
+
fields = rest.split()
|
|
217
|
+
if not fields:
|
|
218
|
+
continue
|
|
219
|
+
try:
|
|
220
|
+
values[name] = int(fields[0]) // 1024 # kB -> MB
|
|
221
|
+
except ValueError:
|
|
222
|
+
continue
|
|
223
|
+
out = {}
|
|
224
|
+
if "MemTotal" in values:
|
|
225
|
+
out["mem_total_mb"] = values["MemTotal"]
|
|
226
|
+
if "MemAvailable" in values:
|
|
227
|
+
out["mem_used_mb"] = max(0, values["MemTotal"] - values["MemAvailable"])
|
|
228
|
+
if "SwapTotal" in values:
|
|
229
|
+
out["swap_total_mb"] = values["SwapTotal"]
|
|
230
|
+
if "SwapFree" in values:
|
|
231
|
+
out["swap_used_mb"] = max(0, values["SwapTotal"] - values["SwapFree"])
|
|
232
|
+
return out
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def mount_points(mounts_text, is_dir=None):
|
|
236
|
+
"""The real disks behind /proc/mounts, one per device under its shortest
|
|
237
|
+
mount point, so a bind mount isn't reported as a second disk. Off Linux
|
|
238
|
+
there is no mount table, so the one filesystem we can name without
|
|
239
|
+
guessing is the one the application runs from.
|
|
240
|
+
|
|
241
|
+
A mount point has to be a directory. Docker bind-mounts single files, so a
|
|
242
|
+
container lists /etc/hosts, /etc/hostname and /etc/resolv.conf on the
|
|
243
|
+
host's own device, and those paths are short enough to win the
|
|
244
|
+
shortest-path race against the real mount. The test is per-device and
|
|
245
|
+
advisory: a device whose mounts cannot be confirmed keeps its shortest
|
|
246
|
+
one, because reporting no disks is worse than an odd mount point."""
|
|
247
|
+
if not mounts_text:
|
|
248
|
+
return [os.path.abspath(os.sep)]
|
|
249
|
+
is_dir = is_dir or os.path.isdir
|
|
250
|
+
dirs, any_mount = {}, {}
|
|
251
|
+
for line in mounts_text.splitlines():
|
|
252
|
+
fields = line.split()
|
|
253
|
+
if len(fields) < 3:
|
|
254
|
+
continue
|
|
255
|
+
device, mount, fstype = fields[0], fields[1].replace("\\040", " "), fields[2]
|
|
256
|
+
if fstype not in FSTYPES or not mount.startswith("/"):
|
|
257
|
+
continue
|
|
258
|
+
if device not in any_mount or len(mount) < len(any_mount[device]):
|
|
259
|
+
any_mount[device] = mount
|
|
260
|
+
try:
|
|
261
|
+
confirmed = is_dir(mount)
|
|
262
|
+
except OSError:
|
|
263
|
+
confirmed = False
|
|
264
|
+
if confirmed and (device not in dirs or len(mount) < len(dirs[device])):
|
|
265
|
+
dirs[device] = mount
|
|
266
|
+
return sorted({dirs.get(d, m) for d, m in any_mount.items()})
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def disk_rows(points, statvfs=None):
|
|
270
|
+
"""Both figures are df's, so a customer can check them: used is total
|
|
271
|
+
minus free, and total is used plus what's available to us. The blocks
|
|
272
|
+
reserved for root are in neither, which is what makes used/total the
|
|
273
|
+
percentage df prints - and that percentage is what the disk alert is
|
|
274
|
+
about."""
|
|
275
|
+
statvfs = statvfs or getattr(os, "statvfs", None)
|
|
276
|
+
if statvfs is None:
|
|
277
|
+
return []
|
|
278
|
+
# One filesystem can sit behind two devices the kernel calls different: in
|
|
279
|
+
# a container / is the overlay and a bind mount exposes the host disk the
|
|
280
|
+
# overlay's upperdir lives on, so both report the same size and the same
|
|
281
|
+
# free space down to the byte. No device-based rule separates them, so the
|
|
282
|
+
# sizes are what gives it away. Raw bytes, not rounded megabytes, so disks
|
|
283
|
+
# that merely round alike stay apart; two separate but identically sized
|
|
284
|
+
# and identically empty filesystems do collapse into one until something
|
|
285
|
+
# is written to either, and being empty they owe the fullness alert
|
|
286
|
+
# nothing.
|
|
287
|
+
seen = set()
|
|
288
|
+
rows = []
|
|
289
|
+
for mount in points:
|
|
290
|
+
try:
|
|
291
|
+
st = statvfs(mount)
|
|
292
|
+
except OSError:
|
|
293
|
+
continue
|
|
294
|
+
block = st.f_frsize or st.f_bsize
|
|
295
|
+
if not block or not st.f_blocks:
|
|
296
|
+
continue
|
|
297
|
+
used = max(0, st.f_blocks - st.f_bfree) * block
|
|
298
|
+
total = used + st.f_bavail * block
|
|
299
|
+
row = {"mount": mount, "total_mb": total // MB, "used_mb": used // MB}
|
|
300
|
+
if row["total_mb"] < MIN_DISK_MB:
|
|
301
|
+
continue
|
|
302
|
+
# points arrives sorted, so the shortest path wins the collision.
|
|
303
|
+
if (total, used) in seen:
|
|
304
|
+
continue
|
|
305
|
+
seen.add((total, used))
|
|
306
|
+
# A filesystem can refuse a write with gigabytes free once these meet.
|
|
307
|
+
if st.f_files:
|
|
308
|
+
row["inodes_total"] = st.f_files
|
|
309
|
+
row["inodes_used"] = max(0, st.f_files - st.f_ffree)
|
|
310
|
+
rows.append(row)
|
|
311
|
+
if len(rows) >= MAX_DISKS:
|
|
312
|
+
break
|
|
313
|
+
return rows
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def host_uptime(text):
|
|
317
|
+
"""Seconds since boot from /proc/uptime, or None. A drop between two
|
|
318
|
+
samples is a reboot, which is worth seeing beside a latency spike."""
|
|
319
|
+
try:
|
|
320
|
+
return int(float(text.split()[0]))
|
|
321
|
+
except (IndexError, ValueError):
|
|
322
|
+
return None
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def load_average():
|
|
326
|
+
try:
|
|
327
|
+
one, five, fifteen = os.getloadavg()
|
|
328
|
+
except (OSError, AttributeError):
|
|
329
|
+
return {}
|
|
330
|
+
return {"load1": round(one, 2), "load5": round(five, 2), "load15": round(fifteen, 2)}
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def process_rss_mb(text):
|
|
334
|
+
"""This package runs inside the application, so unlike the agent it can
|
|
335
|
+
honestly report the application's own memory."""
|
|
336
|
+
for line in text.splitlines():
|
|
337
|
+
if line.startswith("VmRSS:"):
|
|
338
|
+
fields = line.split()
|
|
339
|
+
if len(fields) >= 2:
|
|
340
|
+
try:
|
|
341
|
+
return max(1, int(fields[1]) // 1024)
|
|
342
|
+
except ValueError:
|
|
343
|
+
return None
|
|
344
|
+
return None
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
class Resources:
|
|
348
|
+
"""Options: server_name (the server's name as the dashboard shows it)."""
|
|
349
|
+
|
|
350
|
+
def __init__(self, core, server_name=None, **_):
|
|
351
|
+
self.core = core
|
|
352
|
+
self.server_name = str(server_name if server_name is not None else socket.gethostname())[:100]
|
|
353
|
+
import platform as _platform
|
|
354
|
+
self.runtime = "Python %s" % _platform.python_version()
|
|
355
|
+
# minute (ISO) -> row, so a minute sampled twice is kept once.
|
|
356
|
+
self.rows = {}
|
|
357
|
+
self.lock = threading.Lock()
|
|
358
|
+
self.every = EVERY
|
|
359
|
+
self.enabled = True
|
|
360
|
+
self.last_cpu = cpu_fields(read_text("/proc/stat"))
|
|
361
|
+
self.last_disk = disk_counters(read_text("/proc/diskstats"))
|
|
362
|
+
self.last_net = net_counters(read_text("/proc/net/dev"))
|
|
363
|
+
self.last_at = self._now()
|
|
364
|
+
self.sampler = Periodic(self._sample_job, SAMPLE_EVERY) if core.valid else None
|
|
365
|
+
self.job = Periodic(self._report, EVERY) if core.valid else None
|
|
366
|
+
|
|
367
|
+
@staticmethod
|
|
368
|
+
def _now():
|
|
369
|
+
return datetime.now(timezone.utc).timestamp()
|
|
370
|
+
|
|
371
|
+
def sample(self, at=None):
|
|
372
|
+
"""Takes one sample, covering the time since the last one."""
|
|
373
|
+
at = self._now() if at is None else at
|
|
374
|
+
cpu = cpu_fields(read_text("/proc/stat"))
|
|
375
|
+
disk = disk_counters(read_text("/proc/diskstats"))
|
|
376
|
+
net = net_counters(read_text("/proc/net/dev"))
|
|
377
|
+
percent = cpu_percent(
|
|
378
|
+
None if self.last_cpu is None else cpu_times_of(self.last_cpu),
|
|
379
|
+
None if cpu is None else cpu_times_of(cpu),
|
|
380
|
+
)
|
|
381
|
+
elapsed = at - self.last_at
|
|
382
|
+
# The minute the interval started in, not the one it ended in.
|
|
383
|
+
minute = datetime.fromtimestamp(int(self.last_at // 60) * 60, timezone.utc)
|
|
384
|
+
row = {"minute": minute.isoformat().replace("+00:00", "Z")}
|
|
385
|
+
shares = cpu_shares(self.last_cpu, cpu)
|
|
386
|
+
rates = {}
|
|
387
|
+
if self.last_disk and disk:
|
|
388
|
+
rates["disk_read_mbs"] = rate_mbs(self.last_disk[0], disk[0], elapsed)
|
|
389
|
+
rates["disk_write_mbs"] = rate_mbs(self.last_disk[1], disk[1], elapsed)
|
|
390
|
+
if self.last_net and net:
|
|
391
|
+
rates["net_rx_mbs"] = rate_mbs(self.last_net[0], net[0], elapsed)
|
|
392
|
+
rates["net_tx_mbs"] = rate_mbs(self.last_net[1], net[1], elapsed)
|
|
393
|
+
self.last_cpu, self.last_disk, self.last_net, self.last_at = cpu, disk, net, at
|
|
394
|
+
row.update(shares)
|
|
395
|
+
row.update({name: value for name, value in rates.items() if value is not None})
|
|
396
|
+
if percent is not None and elapsed >= MIN_CPU_SECONDS:
|
|
397
|
+
row["cpu_pct"] = percent
|
|
398
|
+
cores = os.cpu_count()
|
|
399
|
+
if cores:
|
|
400
|
+
row["cpu_cores"] = cores
|
|
401
|
+
row.update(load_average())
|
|
402
|
+
row.update(memory_mb(read_text("/proc/meminfo")))
|
|
403
|
+
disks = disk_rows(mount_points(read_text("/proc/mounts")))
|
|
404
|
+
if disks:
|
|
405
|
+
row["disks"] = disks
|
|
406
|
+
rss = process_rss_mb(read_text("/proc/self/status"))
|
|
407
|
+
if rss is not None:
|
|
408
|
+
row["proc_rss_mb"] = rss
|
|
409
|
+
uptime = host_uptime(read_text("/proc/uptime"))
|
|
410
|
+
if uptime is not None:
|
|
411
|
+
row["host_uptime_s"] = uptime
|
|
412
|
+
with self.lock:
|
|
413
|
+
self.rows[row["minute"]] = row
|
|
414
|
+
# Oldest first out: a long outage keeps the recent minutes.
|
|
415
|
+
while len(self.rows) > MAX_ROWS:
|
|
416
|
+
del self.rows[min(self.rows)]
|
|
417
|
+
return row
|
|
418
|
+
|
|
419
|
+
def _sample_job(self):
|
|
420
|
+
self.sample()
|
|
421
|
+
return SAMPLE_EVERY
|
|
422
|
+
|
|
423
|
+
def flush(self):
|
|
424
|
+
"""Sends what's buffered. Rows are kept only when the collector
|
|
425
|
+
couldn't take them and retrying may work."""
|
|
426
|
+
with self.lock:
|
|
427
|
+
minutes = sorted(self.rows)
|
|
428
|
+
rows = [self.rows[minute] for minute in minutes]
|
|
429
|
+
if not rows:
|
|
430
|
+
return True
|
|
431
|
+
body = {"site_id": self.core.site_id, "server_name": self.server_name,
|
|
432
|
+
"runtime": self.runtime, "rows": rows}
|
|
433
|
+
res = self.core.send("resources", "/events/resources", auth="bearer", body=body)
|
|
434
|
+
if res is None:
|
|
435
|
+
return False
|
|
436
|
+
if not getattr(res, "ok", False):
|
|
437
|
+
# Unreachable or storage down: these rows are still good. A 4xx
|
|
438
|
+
# means resending won't make them acceptable.
|
|
439
|
+
if getattr(res, "status", 0) >= 500:
|
|
440
|
+
return False
|
|
441
|
+
self._drop(minutes)
|
|
442
|
+
return True
|
|
443
|
+
try:
|
|
444
|
+
answer = res.json()
|
|
445
|
+
except Exception:
|
|
446
|
+
answer = {}
|
|
447
|
+
if isinstance(answer, dict):
|
|
448
|
+
if int(answer.get("every") or 0) > 0:
|
|
449
|
+
self.every = max(RETRY_EVERY, int(answer["every"]))
|
|
450
|
+
self.enabled = answer.get("enabled") is not False
|
|
451
|
+
self._drop(minutes)
|
|
452
|
+
return True
|
|
453
|
+
|
|
454
|
+
def _drop(self, minutes):
|
|
455
|
+
with self.lock:
|
|
456
|
+
for minute in minutes:
|
|
457
|
+
self.rows.pop(minute, None)
|
|
458
|
+
|
|
459
|
+
def _report(self):
|
|
460
|
+
if not self.core.is_on("resources"):
|
|
461
|
+
# Switched off: keep nothing, and ask again later.
|
|
462
|
+
with self.lock:
|
|
463
|
+
self.rows.clear()
|
|
464
|
+
return max(OFF_EVERY, self.every)
|
|
465
|
+
return self.every if self.flush() else RETRY_EVERY
|
|
466
|
+
|
|
467
|
+
def close(self):
|
|
468
|
+
if self.sampler:
|
|
469
|
+
self.sampler.stop()
|
|
470
|
+
if self.job:
|
|
471
|
+
self.job.stop()
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: analytics-platform
|
|
3
|
+
Version: 0.2.1
|
|
4
|
+
Summary: Firewall, error monitoring and server analytics for your Python server, run from your analytics dashboard.
|
|
5
|
+
Author: steviec87
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Keywords: firewall,waf,rate-limit,error-monitoring,analytics,server-monitoring,django,flask,fastapi,wsgi,asgi
|
|
8
|
+
Classifier: Development Status :: 4 - Beta
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: Intended Audience :: System Administrators
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Framework :: Django
|
|
16
|
+
Classifier: Framework :: Flask
|
|
17
|
+
Classifier: Framework :: FastAPI
|
|
18
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
19
|
+
Classifier: Topic :: Internet :: Log Analysis
|
|
20
|
+
Classifier: Topic :: Security
|
|
21
|
+
Classifier: Topic :: System :: Monitoring
|
|
22
|
+
Requires-Python: >=3.8
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
License-File: LICENSE
|
|
25
|
+
Dynamic: license-file
|
|
26
|
+
|
|
27
|
+
# analytics-platform (Python)
|
|
28
|
+
|
|
29
|
+
One package for your Python server, run from your analytics dashboard: a
|
|
30
|
+
**firewall**, **error monitoring** and **server analytics** (payments,
|
|
31
|
+
sign-ups, leads). Django, Flask, FastAPI and any WSGI or ASGI app. No
|
|
32
|
+
dependencies; Python 3.8 or later.
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
pip install analytics-platform
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Django
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
# settings.py
|
|
42
|
+
MIDDLEWARE = [
|
|
43
|
+
"analytics_platform.django.PlatformMiddleware", # first
|
|
44
|
+
# ...
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
ANALYTICS_PLATFORM = {
|
|
48
|
+
"site_id": "YOUR_SITE_ID",
|
|
49
|
+
"secret": os.environ["ANALYTICS_SITE_SECRET"], # site settings; keep it on the server
|
|
50
|
+
"endpoint": "https://YOUR_COLLECTOR_URL",
|
|
51
|
+
# "trust_proxy": 1, # behind Nginx or a load balancer
|
|
52
|
+
# "trust_proxy": "cloudflare", # every request through Cloudflare
|
|
53
|
+
}
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
In your code: `from analytics_platform.django import get_platform`.
|
|
57
|
+
|
|
58
|
+
## FastAPI / Starlette
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
from analytics_platform import create_platform, PlatformMiddleware
|
|
62
|
+
|
|
63
|
+
platform = create_platform(site_id="YOUR_SITE_ID", secret=os.environ["ANALYTICS_SITE_SECRET"],
|
|
64
|
+
endpoint="https://YOUR_COLLECTOR_URL")
|
|
65
|
+
app.add_middleware(PlatformMiddleware, platform=platform)
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Flask
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
platform = create_platform(site_id="YOUR_SITE_ID", secret=os.environ["ANALYTICS_SITE_SECRET"],
|
|
72
|
+
endpoint="https://YOUR_COLLECTOR_URL")
|
|
73
|
+
platform.init_flask(app)
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Other WSGI apps: `app = platform.wsgi(app)`; other ASGI apps: `app = platform.asgi(app)`.
|
|
77
|
+
|
|
78
|
+
## Modules
|
|
79
|
+
|
|
80
|
+
| Module | What it does | Default |
|
|
81
|
+
|---|---|---|
|
|
82
|
+
| `firewall` | Blocks addresses, known criminal networks, shared blocks and floods (Security → Firewall) | on |
|
|
83
|
+
| `firewall`'s attack patterns | Rules that match the *request* (its path, query string and user agent), not just the address, so an attack is turned away before your code runs — a package with a known hole stays safe until you update it. They come with the firewall's rules and are managed on the Security page (Firewall → Attack patterns); a pattern a real visitor might trip only watches until you promote it. Nothing to configure (protocol: `sdk/protocols/firewall.md`) | with the firewall |
|
|
84
|
+
| `proxy` | Serves the tracker and takes visits under your own domain, so ad blockers can't hide those visitors | on |
|
|
85
|
+
| `errors` | Sends your server's errors to the Errors report | on |
|
|
86
|
+
| `analytics` | `track()`, `identify()` and `lead()` from your server | on |
|
|
87
|
+
| `audit` | `platform.audit("user.role_changed", actor=..., target=..., request=...)`; Django logins and logouts on their own | on |
|
|
88
|
+
| `flags` | `platform.is_enabled("new-checkout", user=user.id)`: features on or off from the dashboard | on |
|
|
89
|
+
| `maintenance` | A "back soon" page while maintenance mode is on in the dashboard | on |
|
|
90
|
+
| `headers` | Adds the security headers you pick on the Security page; your own headers win | on |
|
|
91
|
+
| `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 |
|
|
93
|
+
| `crawlers` | Which search engines and AI bots fetch your pages (Health → Crawlers). Django counts them for you; elsewhere call `platform.record_crawler(...)` | on |
|
|
94
|
+
| `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
|
+
| `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 |
|
|
96
|
+
|
|
97
|
+
Each takes `True`, `False` or its options as a dict:
|
|
98
|
+
|
|
99
|
+
```python
|
|
100
|
+
create_platform(site_id=..., secret=..., endpoint=...,
|
|
101
|
+
firewall={"mode": "monitor", "cache_file": "/tmp/firewall-rules.json"},
|
|
102
|
+
errors={"capture_uncaught": False},
|
|
103
|
+
analytics=False)
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
**Switch modules off in the dashboard** (Security → Settings → Server package) without a
|
|
107
|
+
deploy: the package checks about once a minute. Modules are independent: one
|
|
108
|
+
failing to start, or the collector being down, never stops the others, and
|
|
109
|
+
never slows your requests. Background work runs in daemon threads, which start
|
|
110
|
+
again in each worker after a fork (gunicorn, uWSGI).
|
|
111
|
+
|
|
112
|
+
## Firewall
|
|
113
|
+
|
|
114
|
+
Rules, monitor or block mode and reports are in the dashboard (Security →
|
|
115
|
+
Firewall). New sites start in monitor mode.
|
|
116
|
+
|
|
117
|
+
Options: `mode` (`"monitor"` or `"block"`, overrides the dashboard),
|
|
118
|
+
`cache_file` (keep rules between restarts), `trust_proxy`, `counter`.
|
|
119
|
+
|
|
120
|
+
**The visitor's address:** behind a proxy, every request comes from the proxy
|
|
121
|
+
unless you set `trust_proxy`: `1` for one proxy or load balancer, `2` for
|
|
122
|
+
Cloudflare in front of a load balancer, `"cloudflare"` when every request comes
|
|
123
|
+
through Cloudflare. (Uvicorn with `--proxy-headers` already does this: leave it unset.)
|
|
124
|
+
|
|
125
|
+
**Rate limit with several workers:** each worker counts on its own, so the
|
|
126
|
+
limit is per worker. For one count, pass a shared counter:
|
|
127
|
+
|
|
128
|
+
```python
|
|
129
|
+
def counter(key, ttl):
|
|
130
|
+
hits = redis.incr(key)
|
|
131
|
+
if hits == 1:
|
|
132
|
+
redis.expire(key, ttl)
|
|
133
|
+
return hits
|
|
134
|
+
|
|
135
|
+
create_platform(..., firewall={"counter": counter})
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
**Bot protection:** Django's failed logins are counted on their own; elsewhere
|
|
139
|
+
call `platform.login_failed(request)` when a login fails. Past the site's limit
|
|
140
|
+
the address is banned here straight away and on your other servers within a
|
|
141
|
+
minute or two. Floods (over the rate limit) are banned the same way.
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
**Probes.** Requests for files no visitor ever asks for (`/.env`, `/.git/…`,
|
|
145
|
+
backups: the list comes with the rules) are rule `probe`; in block mode the
|
|
146
|
+
address is also banned on this server, and the collector bans it on the
|
|
147
|
+
site's other servers.
|
|
148
|
+
## Error monitoring
|
|
149
|
+
|
|
150
|
+
Uncaught exceptions are sent on their own; request errors through the
|
|
151
|
+
middleware (Django, Flask, FastAPI). Errors you catch:
|
|
152
|
+
|
|
153
|
+
```python
|
|
154
|
+
try:
|
|
155
|
+
charge_card(order)
|
|
156
|
+
except PaymentError as error:
|
|
157
|
+
platform.capture_exception(error, status=502)
|
|
158
|
+
raise
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
Options: `server_name`, `root` (traceback paths relative to it),
|
|
162
|
+
`capture_uncaught`, `before_send(event, error)` (change an error, or return
|
|
163
|
+
`None` to drop it).
|
|
164
|
+
|
|
165
|
+
## Server analytics
|
|
166
|
+
|
|
167
|
+
For what only your server knows for sure. Not pageviews: the tracker counts those.
|
|
168
|
+
|
|
169
|
+
```python
|
|
170
|
+
session = platform.session_id(request) # the visit, from the X-Analytics-Session header
|
|
171
|
+
|
|
172
|
+
platform.track("purchase", {"numeric_value": "49.90", "currency": "EUR", "event_uid": f"order-{order.id}"}, session_id=session)
|
|
173
|
+
platform.identify(user.id, session_id=session)
|
|
174
|
+
platform.lead({"email": "ana@example.com", "name": "Ana", "status": "qualified"})
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
`track()` sends in the background; `identify()` and `lead()` wait for the
|
|
178
|
+
answer (up to 5 seconds).
|
|
179
|
+
|
|
180
|
+
**Link to the visit:** send the tracker's session ID with your site's requests:
|
|
181
|
+
|
|
182
|
+
```js
|
|
183
|
+
fetch("/api/orders", { headers: { "x-analytics-session": window.analyticsSession?.getId() || "" }, ... });
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
## Ad blockers
|
|
187
|
+
|
|
188
|
+
Many visitors use an ad blocker, and most blockers stop analytics. They
|
|
189
|
+
recognise **the address a script comes from** and **what it is called**, so a
|
|
190
|
+
tracker loaded from us is simply never counted.
|
|
191
|
+
|
|
192
|
+
This package already runs on your own domain, so it serves the tracker and
|
|
193
|
+
takes the visits itself. Nothing to set up: use the two addresses it gives you
|
|
194
|
+
in your page's template.
|
|
195
|
+
|
|
196
|
+
```python
|
|
197
|
+
platform = get_platform() # Django; elsewhere your own platform
|
|
198
|
+
context = {
|
|
199
|
+
"script_url": platform.proxy.script_url(), # /3f9a2c1b/js/v1/s.js
|
|
200
|
+
"collect_url": platform.proxy.collect_url(), # /3f9a2c1b/api/e
|
|
201
|
+
}
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
```html
|
|
205
|
+
<script async src="{{ script_url }}" data-siteid="..." data-domain="example.com"
|
|
206
|
+
data-collect="{{ collect_url }}"></script>
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
- The path comes from your secret, so it isn't in the page and isn't a name
|
|
210
|
+
any blocker lists. Pick your own with `proxy={"prefix": "..."}`.
|
|
211
|
+
- Tracker files are fetched once and then held in memory; if we can't be
|
|
212
|
+
reached, the copy already held is served rather than nothing.
|
|
213
|
+
- The visitor's address goes on as `X-Forwarded-For` (your collector needs it
|
|
214
|
+
for the country, the daily visitor ID and the rate limit) and cookies never
|
|
215
|
+
leave your server.
|
|
216
|
+
- Beacons are answered before the firewall, the crawler count and the timing,
|
|
217
|
+
so your own analytics traffic doesn't appear as pages of your site. On ASGI
|
|
218
|
+
the send runs in a thread, so the event loop keeps turning.
|
|
219
|
+
- Works through the Django, WSGI (Flask, Bottle, Pyramid) and ASGI (FastAPI,
|
|
220
|
+
Starlette) middleware alike.
|
|
221
|
+
- Switch it off with `proxy=False`, or in the dashboard (Site settings →
|
|
222
|
+
Server). Then load the tracker from us as before.
|
|
223
|
+
|
|
224
|
+
## Shutting down and serverless
|
|
225
|
+
|
|
226
|
+
`platform.flush()` sends everything waiting (on Lambda, before returning).
|
|
227
|
+
`platform.close()` also stops the background threads.
|