mod-wsgi-telemetry 1.0.0.dev2__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.
- mod_wsgi/__init__.py +2 -0
- mod_wsgi/telemetry/__init__.py +1 -0
- mod_wsgi/telemetry/cli.py +55 -0
- mod_wsgi/telemetry/contention.py +229 -0
- mod_wsgi/telemetry/dump.py +102 -0
- mod_wsgi/telemetry/ingest.py +773 -0
- mod_wsgi/telemetry/server.py +270 -0
- mod_wsgi/telemetry/simulate.py +649 -0
- mod_wsgi/telemetry/static/index.html +8368 -0
- mod_wsgi/telemetry/tui.py +1536 -0
- mod_wsgi/telemetry/wire.py +480 -0
- mod_wsgi_telemetry-1.0.0.dev2.dist-info/METADATA +79 -0
- mod_wsgi_telemetry-1.0.0.dev2.dist-info/RECORD +16 -0
- mod_wsgi_telemetry-1.0.0.dev2.dist-info/WHEEL +4 -0
- mod_wsgi_telemetry-1.0.0.dev2.dist-info/entry_points.txt +2 -0
- mod_wsgi_telemetry-1.0.0.dev2.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,649 @@
|
|
|
1
|
+
"""Emit synthetic telemetry samples so the ingester and UI can be tested
|
|
2
|
+
without a running mod_wsgi.
|
|
3
|
+
|
|
4
|
+
Produces plausibly-shaped request_metrics samples for N fake processes,
|
|
5
|
+
one per interval. Values oscillate over time so charts show movement.
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
mod_wsgi-telemetry simulate \\
|
|
9
|
+
--target unix:/tmp/mod_wsgi-telemetry.sock \\
|
|
10
|
+
--processes 4 --interval 1.0
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import argparse
|
|
16
|
+
import math
|
|
17
|
+
import os
|
|
18
|
+
import random
|
|
19
|
+
import socket
|
|
20
|
+
import sys
|
|
21
|
+
import time
|
|
22
|
+
|
|
23
|
+
from .wire import (
|
|
24
|
+
KIND_PROCESS_STARTED,
|
|
25
|
+
KIND_PROCESS_STOPPED,
|
|
26
|
+
KIND_PROCESS_STOPPING,
|
|
27
|
+
KIND_REQUEST,
|
|
28
|
+
KIND_SLOW_REQUEST,
|
|
29
|
+
Sample,
|
|
30
|
+
encode,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# Handler paths the simulator cycles through so the Slow Requests tab
|
|
35
|
+
# shows variety. Includes one with a URL long enough to exercise the
|
|
36
|
+
# ellipsis-truncation path in the UI.
|
|
37
|
+
_SLOW_PATHS = [
|
|
38
|
+
("GET", "/api/reports/render"),
|
|
39
|
+
("POST", "/api/orders/checkout"),
|
|
40
|
+
("GET", "/admin/users/export"),
|
|
41
|
+
("POST", "/api/image/thumbnail"),
|
|
42
|
+
("GET", "/api/search/" + "aaa/" * 60 + "end"),
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def parse_target(spec: str) -> tuple[int, str]:
|
|
47
|
+
"""Return (family, addr) for socket.sendto. UNIX SOCK_DGRAM only."""
|
|
48
|
+
if spec.startswith("unix:"):
|
|
49
|
+
return socket.AF_UNIX, spec[len("unix:"):]
|
|
50
|
+
raise ValueError(
|
|
51
|
+
f"unknown scheme in target {spec!r}: expected 'unix:/path' "
|
|
52
|
+
f"(remote 'udp:host:port' targets are no longer supported)"
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
_SLOT_COUNT = 5
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _class_split(count: int) -> dict:
|
|
60
|
+
"""Partition `count` requests into per-class HTTP totals.
|
|
61
|
+
|
|
62
|
+
Mostly 2xx, a smaller share of 3xx / 4xx, a small 5xx tail so the
|
|
63
|
+
UI's error-rate panels exercise their colouring. 1xx stays at zero
|
|
64
|
+
(a WSGI app should never return 1xx — the counter is a tripwire,
|
|
65
|
+
not a baseline). Sum equals `count` exactly.
|
|
66
|
+
"""
|
|
67
|
+
if count <= 0:
|
|
68
|
+
return {
|
|
69
|
+
"status_1xx_total": 0, "status_2xx_total": 0,
|
|
70
|
+
"status_3xx_total": 0, "status_4xx_total": 0,
|
|
71
|
+
"status_5xx_total": 0,
|
|
72
|
+
}
|
|
73
|
+
c5 = max(1, count // 100) if random.random() < 0.4 else 0
|
|
74
|
+
c4 = max(1, count * random.randint(2, 5) // 100)
|
|
75
|
+
c3 = max(1, count * random.randint(3, 8) // 100)
|
|
76
|
+
c2 = count - c5 - c4 - c3
|
|
77
|
+
if c2 < 0:
|
|
78
|
+
c2, c3, c4, c5 = count, 0, 0, 0
|
|
79
|
+
return {
|
|
80
|
+
"status_1xx_total": 0,
|
|
81
|
+
"status_2xx_total": c2,
|
|
82
|
+
"status_3xx_total": c3,
|
|
83
|
+
"status_4xx_total": c4,
|
|
84
|
+
"status_5xx_total": c5,
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def make_sample(pid: int, seq: int, phase: float, interval: float,
|
|
89
|
+
slot_state: dict | None = None) -> Sample:
|
|
90
|
+
"""Build one synthetic request_metrics sample.
|
|
91
|
+
|
|
92
|
+
slot_state is a per-process dict carrying stateful slot info across
|
|
93
|
+
ticks: which slot (if any) is currently "stuck" on a long request,
|
|
94
|
+
and how long it's been stuck. Mutated in place.
|
|
95
|
+
"""
|
|
96
|
+
base = 100 + 60 * math.sin(phase)
|
|
97
|
+
jitter = random.uniform(-10, 10)
|
|
98
|
+
throughput = max(0.0, base + jitter)
|
|
99
|
+
cap = min(1.0, throughput / 200.0)
|
|
100
|
+
cpu_user = cap * random.uniform(0.35, 0.55)
|
|
101
|
+
cpu_sys = cap * random.uniform(0.05, 0.12)
|
|
102
|
+
|
|
103
|
+
count = int(throughput * interval)
|
|
104
|
+
app_time = 0.02 + 0.05 * cap + random.uniform(-0.003, 0.003)
|
|
105
|
+
srv_time = app_time + random.uniform(0.001, 0.004)
|
|
106
|
+
queue_time = random.uniform(0.0005, 0.003)
|
|
107
|
+
daemon_time = random.uniform(0.0005, 0.002)
|
|
108
|
+
request_time = srv_time + queue_time + daemon_time + app_time
|
|
109
|
+
|
|
110
|
+
# HDR layout: 16 octaves × 4 sub-buckets + 1 overflow = 65. Bucket
|
|
111
|
+
# index for a duration ms is octave*4 + sub, where octave is
|
|
112
|
+
# floor(log2(ms)) and sub is which quarter of [2^o, 2^(o+1)) the
|
|
113
|
+
# value lands in. concentration here picks the centre bucket index
|
|
114
|
+
# to spread the gaussian around — e.g. concentration=18 centres
|
|
115
|
+
# around the [20, 24) ms sub-bucket of the 16-32 ms octave.
|
|
116
|
+
_N_BUCKETS = 65
|
|
117
|
+
|
|
118
|
+
def bucketise(total: int, concentration: int) -> list[int]:
|
|
119
|
+
buckets = [0] * _N_BUCKETS
|
|
120
|
+
for _ in range(total):
|
|
121
|
+
idx = max(0, min(_N_BUCKETS - 1,
|
|
122
|
+
int(random.gauss(concentration, 4.0))))
|
|
123
|
+
buckets[idx] += 1
|
|
124
|
+
return buckets
|
|
125
|
+
|
|
126
|
+
def minmax_seconds(seconds_mean: float) -> tuple[float, float] | tuple[None, None]:
|
|
127
|
+
"""Plausible per-tick min/max bracketing the mean (float seconds)."""
|
|
128
|
+
if count == 0:
|
|
129
|
+
# No requests this tick: encoder skips the field. Return
|
|
130
|
+
# (None, None) so the field is simply absent on the wire.
|
|
131
|
+
return None, None
|
|
132
|
+
mn = max(1e-6, seconds_mean * random.uniform(0.30, 0.55))
|
|
133
|
+
mx = max(mn + 1e-6, seconds_mean * random.uniform(3.0, 8.0))
|
|
134
|
+
return mn, mx
|
|
135
|
+
|
|
136
|
+
s_min, s_max = minmax_seconds(srv_time)
|
|
137
|
+
a_min, a_max = minmax_seconds(app_time)
|
|
138
|
+
r_min, r_max = minmax_seconds(request_time)
|
|
139
|
+
q_min, q_max = minmax_seconds(queue_time)
|
|
140
|
+
d_min, d_max = minmax_seconds(daemon_time)
|
|
141
|
+
|
|
142
|
+
# Per-slot capacity arrays. Each slot's base busy-fraction is a
|
|
143
|
+
# blend of the process-wide capacity and per-slot jitter, so slots
|
|
144
|
+
# diverge visibly on the heatmap even when the process average is
|
|
145
|
+
# steady. One slot may be "stuck" on a long request — its
|
|
146
|
+
# current_elapsed climbs across ticks and its busy-time saturates.
|
|
147
|
+
if slot_state is None:
|
|
148
|
+
slot_state = {}
|
|
149
|
+
|
|
150
|
+
# Randomly start or release a stuck slot so the UI shows activity.
|
|
151
|
+
stuck_slot = slot_state.get("stuck_slot")
|
|
152
|
+
stuck_since = slot_state.get("stuck_since", 0.0)
|
|
153
|
+
if stuck_slot is None:
|
|
154
|
+
if cap > 0.4 and random.random() < 0.05:
|
|
155
|
+
stuck_slot = random.randrange(_SLOT_COUNT)
|
|
156
|
+
stuck_since = 0.0
|
|
157
|
+
else:
|
|
158
|
+
stuck_since += interval
|
|
159
|
+
if stuck_since > 15.0 or random.random() < 0.08:
|
|
160
|
+
stuck_slot = None
|
|
161
|
+
stuck_since = 0.0
|
|
162
|
+
slot_state["stuck_slot"] = stuck_slot
|
|
163
|
+
slot_state["stuck_since"] = stuck_since
|
|
164
|
+
|
|
165
|
+
request_threads_completed = [0] * _SLOT_COUNT
|
|
166
|
+
request_threads_busy_time = [0.0] * _SLOT_COUNT
|
|
167
|
+
request_threads_cpu_time = [0.0] * _SLOT_COUNT
|
|
168
|
+
request_threads_current_elapsed = [0.0] * _SLOT_COUNT
|
|
169
|
+
request_threads_max_duration = [0.0] * _SLOT_COUNT
|
|
170
|
+
|
|
171
|
+
for s in range(_SLOT_COUNT):
|
|
172
|
+
if s == stuck_slot:
|
|
173
|
+
# Pinned on a long request: busy near 100%, no completions,
|
|
174
|
+
# current_elapsed climbing.
|
|
175
|
+
request_threads_busy_time[s] = interval * random.uniform(0.92, 1.0)
|
|
176
|
+
request_threads_cpu_time[s] = (
|
|
177
|
+
request_threads_busy_time[s] * random.uniform(0.05, 0.25))
|
|
178
|
+
request_threads_current_elapsed[s] = stuck_since
|
|
179
|
+
request_threads_max_duration[s] = stuck_since
|
|
180
|
+
request_threads_completed[s] = 0
|
|
181
|
+
else:
|
|
182
|
+
slot_cap = max(0.0, min(1.0, cap + random.uniform(-0.25, 0.25)))
|
|
183
|
+
request_threads_busy_time[s] = interval * slot_cap
|
|
184
|
+
cpu_frac = random.uniform(0.3, 0.8)
|
|
185
|
+
request_threads_cpu_time[s] = (
|
|
186
|
+
request_threads_busy_time[s] * cpu_frac)
|
|
187
|
+
request_threads_completed[s] = max(
|
|
188
|
+
0, int(slot_cap * count / max(1, _SLOT_COUNT - (1 if stuck_slot is not None else 0))
|
|
189
|
+
+ random.uniform(-2, 2)))
|
|
190
|
+
# Heavy-tailed max-duration per tick (in seconds).
|
|
191
|
+
if request_threads_completed[s] > 0:
|
|
192
|
+
request_threads_max_duration[s] = (
|
|
193
|
+
max(0.005, random.lognormvariate(3.5, 0.8) / 1000.0)
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
rss = 120 * 1024 * 1024 + int(10 * 1024 * 1024 * math.sin(phase / 3))
|
|
197
|
+
|
|
198
|
+
fields = {
|
|
199
|
+
"hostname": socket.gethostname(),
|
|
200
|
+
"process_group": "simulated",
|
|
201
|
+
"process_parent_pid": os.getpid(),
|
|
202
|
+
"sample_period": float(interval),
|
|
203
|
+
"request_count": count,
|
|
204
|
+
"request_throughput": throughput,
|
|
205
|
+
"capacity_utilization": cap,
|
|
206
|
+
"cpu_user_utilization": cpu_user,
|
|
207
|
+
"cpu_system_utilization": cpu_sys,
|
|
208
|
+
"cpu_utilization": cpu_user + cpu_sys,
|
|
209
|
+
"memory_rss": rss,
|
|
210
|
+
"memory_max_rss": rss + 8 * 1024 * 1024,
|
|
211
|
+
"request_threads_maximum": _SLOT_COUNT,
|
|
212
|
+
"request_threads_active": max(1, min(_SLOT_COUNT,
|
|
213
|
+
sum(1 for v in request_threads_busy_time
|
|
214
|
+
if v > 0))),
|
|
215
|
+
"server_time": srv_time,
|
|
216
|
+
"queue_time": queue_time,
|
|
217
|
+
"daemon_time": daemon_time,
|
|
218
|
+
"application_time": app_time,
|
|
219
|
+
"request_time": request_time,
|
|
220
|
+
# HDR concentrations: 18 ≈ [20, 24) ms, 14 ≈ [10, 12) ms,
|
|
221
|
+
# 22 ≈ [40, 48) ms — chosen to mirror the seconds_mean above.
|
|
222
|
+
"application_time_buckets": bucketise(count, concentration=18),
|
|
223
|
+
"server_time_buckets": bucketise(count, concentration=14),
|
|
224
|
+
"queue_time_buckets": bucketise(count, concentration=8),
|
|
225
|
+
"daemon_time_buckets": bucketise(count, concentration=8),
|
|
226
|
+
"request_time_buckets": bucketise(count, concentration=22),
|
|
227
|
+
"request_threads_completed": request_threads_completed,
|
|
228
|
+
"request_threads_busy_time": request_threads_busy_time,
|
|
229
|
+
"request_threads_cpu_time": request_threads_cpu_time,
|
|
230
|
+
"request_threads_current_elapsed": request_threads_current_elapsed,
|
|
231
|
+
"request_threads_max_duration": request_threads_max_duration,
|
|
232
|
+
# Per-request avg ~256 B in / ~1 KB out, with a small tail of
|
|
233
|
+
# streaming responses that bump output_writes well above the
|
|
234
|
+
# request count so the UI's "bytes/write" smell threshold is
|
|
235
|
+
# exercised on demo data.
|
|
236
|
+
"input_bytes_total": count * 256,
|
|
237
|
+
"input_reads_total": count,
|
|
238
|
+
"output_bytes_total": count * 1024,
|
|
239
|
+
"output_writes_total": count + (count // 5) * 50,
|
|
240
|
+
# Per-class HTTP response totals. Mostly 2xx, a sprinkle of 3xx
|
|
241
|
+
# / 4xx, and a small 5xx tail so the UI's error-rate badges
|
|
242
|
+
# exercise their colouring on demo data. Sum equals the
|
|
243
|
+
# request_count above (1xx + 2xx + 3xx + 4xx + 5xx == count).
|
|
244
|
+
**_class_split(count),
|
|
245
|
+
# Mirror what a real reporter emits: telemetry interval is the
|
|
246
|
+
# tick we're simulating, slow_requests_threshold pretends a
|
|
247
|
+
# WSGISlowRequests of 1 s so the UI's "below server threshold"
|
|
248
|
+
# warning can be exercised by setting the heatmap stuck
|
|
249
|
+
# threshold to 0.5 s.
|
|
250
|
+
"telemetry_interval": float(interval),
|
|
251
|
+
"slow_requests_threshold": 1.0,
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
# Per-phase exact min/max (float seconds). Skip the field on idle
|
|
255
|
+
# ticks to mirror the C encoder's "field absent when no requests
|
|
256
|
+
# this tick" behaviour.
|
|
257
|
+
for key, val in (
|
|
258
|
+
("server_time_min", s_min),
|
|
259
|
+
("server_time_max", s_max),
|
|
260
|
+
("queue_time_min", q_min),
|
|
261
|
+
("queue_time_max", q_max),
|
|
262
|
+
("daemon_time_min", d_min),
|
|
263
|
+
("daemon_time_max", d_max),
|
|
264
|
+
("application_time_min", a_min),
|
|
265
|
+
("application_time_max", a_max),
|
|
266
|
+
("request_time_min", r_min),
|
|
267
|
+
("request_time_max", r_max),
|
|
268
|
+
):
|
|
269
|
+
if val is not None:
|
|
270
|
+
fields[key] = val
|
|
271
|
+
|
|
272
|
+
return Sample(
|
|
273
|
+
version=1,
|
|
274
|
+
kind=KIND_REQUEST,
|
|
275
|
+
pid=pid,
|
|
276
|
+
seq=seq,
|
|
277
|
+
stamp=time.time(),
|
|
278
|
+
fields=fields,
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def make_slow_sample(pid: int, seq: int, state: int, thread_id: int,
|
|
283
|
+
log_id: str, method: str, path: str,
|
|
284
|
+
start_stamp: float, duration: float) -> Sample:
|
|
285
|
+
"""Build one synthetic slow_request record."""
|
|
286
|
+
# Synthesise plausible per-request I/O so the slow-request expand
|
|
287
|
+
# panel shows non-zero counters in the simulator. POSTs get a body
|
|
288
|
+
# in; the long /api/search/* path streams a big response across
|
|
289
|
+
# many writes so the UI's smell tinting kicks in.
|
|
290
|
+
if method == "POST":
|
|
291
|
+
in_bytes = random.randint(2_000, 50_000)
|
|
292
|
+
in_reads = random.randint(1, 4)
|
|
293
|
+
else:
|
|
294
|
+
in_bytes = 0
|
|
295
|
+
in_reads = 0
|
|
296
|
+
streaming = "search" in path
|
|
297
|
+
out_bytes = random.randint(50_000_000, 150_000_000) if streaming \
|
|
298
|
+
else random.randint(2_000, 200_000)
|
|
299
|
+
out_writes = random.randint(40_000, 120_000) if streaming \
|
|
300
|
+
else random.randint(1, 10)
|
|
301
|
+
# Synthesise plausible CPU time so the UI's CPU% indicator (and
|
|
302
|
+
# its amber / multi-core tinting) is exercisable on demo data.
|
|
303
|
+
# Streaming endpoints look I/O-bound (low CPU%); checkout looks
|
|
304
|
+
# multi-core (>100% via C-extension threading); the rest land in
|
|
305
|
+
# the mixed band so the colouring isn't all one shade.
|
|
306
|
+
if state == 1:
|
|
307
|
+
if streaming:
|
|
308
|
+
cpu_total = duration * random.uniform(0.05, 0.20)
|
|
309
|
+
elif "checkout" in path:
|
|
310
|
+
cpu_total = duration * random.uniform(1.10, 1.40)
|
|
311
|
+
elif "thumbnail" in path:
|
|
312
|
+
cpu_total = duration * random.uniform(0.85, 0.98)
|
|
313
|
+
else:
|
|
314
|
+
cpu_total = duration * random.uniform(0.30, 0.60)
|
|
315
|
+
cpu_user_time = cpu_total * random.uniform(0.85, 0.95)
|
|
316
|
+
cpu_system_time = cpu_total - cpu_user_time
|
|
317
|
+
else:
|
|
318
|
+
# Active records carry zero CPU on the wire today (see C side).
|
|
319
|
+
cpu_user_time = 0.0
|
|
320
|
+
cpu_system_time = 0.0
|
|
321
|
+
# Active records carry status 0 (start_response not yet called).
|
|
322
|
+
# Completed records mostly land at 200; sprinkle in a few 500s so
|
|
323
|
+
# the slow table's status column exercises its 5xx colouring.
|
|
324
|
+
if state == 1:
|
|
325
|
+
slow_status = 500 if random.random() < 0.05 else 200
|
|
326
|
+
else:
|
|
327
|
+
slow_status = 0
|
|
328
|
+
# Synthesise plausible per-phase timings so the UI's breakdown is
|
|
329
|
+
# exercisable on demo data. server / queue / daemon are small
|
|
330
|
+
# fixed-ish overheads; application is the residual. Active records
|
|
331
|
+
# report an in-flight application_time partial.
|
|
332
|
+
server_time = random.uniform(0.0005, 0.003)
|
|
333
|
+
queue_time = random.uniform(0.0005, 0.005)
|
|
334
|
+
daemon_time = random.uniform(0.0005, 0.003)
|
|
335
|
+
overhead = server_time + queue_time + daemon_time
|
|
336
|
+
application_time = max(0.0, duration - overhead)
|
|
337
|
+
fields = {
|
|
338
|
+
"slow_record_state": state, # 0=active, 1=completed
|
|
339
|
+
"slow_start_stamp": start_stamp,
|
|
340
|
+
"slow_duration": duration,
|
|
341
|
+
"slow_thread_id": thread_id,
|
|
342
|
+
# Synthesise an Apache-child pid distinct from the emitter
|
|
343
|
+
# pid so the slow-request drill-down exercises its daemon-mode
|
|
344
|
+
# rendering (proxy != daemon). Stable per emitter.
|
|
345
|
+
"slow_server_pid": max(1, pid - 1),
|
|
346
|
+
"slow_log_id": log_id,
|
|
347
|
+
"slow_method": method,
|
|
348
|
+
"slow_scheme": "http",
|
|
349
|
+
"slow_hostname": socket.gethostname(),
|
|
350
|
+
"slow_script_name": "",
|
|
351
|
+
"slow_path_info": path,
|
|
352
|
+
"slow_input_bytes": in_bytes,
|
|
353
|
+
"slow_input_reads": in_reads,
|
|
354
|
+
"slow_output_bytes": out_bytes,
|
|
355
|
+
"slow_output_writes": out_writes,
|
|
356
|
+
"slow_cpu_user_time": cpu_user_time,
|
|
357
|
+
"slow_cpu_system_time": cpu_system_time,
|
|
358
|
+
"slow_server_time": server_time,
|
|
359
|
+
"slow_queue_time": queue_time,
|
|
360
|
+
"slow_daemon_time": daemon_time,
|
|
361
|
+
"slow_application_time": application_time,
|
|
362
|
+
# Synthesise plausible concurrency: pretend a 15-thread worker
|
|
363
|
+
# with 2-12 in flight at slot claim, drifting up or down a
|
|
364
|
+
# little by completion. Exercises the saturation indicator.
|
|
365
|
+
"slow_active_at_start": random.randint(2, 12),
|
|
366
|
+
"slow_active_at_completion": (
|
|
367
|
+
random.randint(2, 12) if state == 1 else 0),
|
|
368
|
+
# Synthesise a peer IP (mostly RFC1918, occasional IPv6) and
|
|
369
|
+
# a protocol so the demo UI exercises both columns. Real-world
|
|
370
|
+
# values come from r->useragent_ip and SERVER_PROTOCOL.
|
|
371
|
+
"slow_peer_ip": random.choice([
|
|
372
|
+
f"10.0.{random.randint(0,255)}.{random.randint(1,254)}",
|
|
373
|
+
f"192.168.1.{random.randint(2,254)}",
|
|
374
|
+
"203.0.113.42",
|
|
375
|
+
"2001:db8::5",
|
|
376
|
+
]),
|
|
377
|
+
"slow_protocol": random.choice(["HTTP/1.1", "HTTP/2.0"]),
|
|
378
|
+
# User-Agent is opt-in via WSGITelemetryOptions; the simulator
|
|
379
|
+
# always emits one so the demo UI exercises the column. Real
|
|
380
|
+
# mod_wsgi installs see this only when the operator configures
|
|
381
|
+
# +CaptureUserAgent.
|
|
382
|
+
"slow_user_agent": random.choice([
|
|
383
|
+
"Mozilla/5.0 (X11; Linux x86_64) Gecko/20100101 Firefox/137.0",
|
|
384
|
+
"curl/8.7.1",
|
|
385
|
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 14_5) "
|
|
386
|
+
"AppleWebKit/605.1.15 Safari/605.1.15",
|
|
387
|
+
"python-requests/2.32.0",
|
|
388
|
+
"GoogleBot/2.1 (+http://www.google.com/bot.html)",
|
|
389
|
+
]),
|
|
390
|
+
"slow_status": slow_status,
|
|
391
|
+
}
|
|
392
|
+
return Sample(
|
|
393
|
+
version=1,
|
|
394
|
+
kind=KIND_SLOW_REQUEST,
|
|
395
|
+
pid=pid,
|
|
396
|
+
seq=seq,
|
|
397
|
+
stamp=time.time(),
|
|
398
|
+
fields=fields,
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
# Simulator restart-reason rotation. Order matters only for visual
|
|
403
|
+
# variety in the demo; the categorisation map on the UI side is what
|
|
404
|
+
# actually drives marker colour. Includes one of each category so the
|
|
405
|
+
# operator immediately sees three colours of marker.
|
|
406
|
+
_SIM_REASONS = [
|
|
407
|
+
"restart_interval", # planned
|
|
408
|
+
"maximum_requests", # planned
|
|
409
|
+
"script_reload", # planned
|
|
410
|
+
"cpu_time_limit", # tuning
|
|
411
|
+
"request_timeout", # tuning
|
|
412
|
+
"graceful_signal", # tuning
|
|
413
|
+
"deadlock_timeout", # unplanned
|
|
414
|
+
"shutdown_signal", # unplanned
|
|
415
|
+
]
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def make_started_sample(pid: int, seq: int, parent_pid: int) -> Sample:
|
|
419
|
+
return Sample(
|
|
420
|
+
version=1,
|
|
421
|
+
kind=KIND_PROCESS_STARTED,
|
|
422
|
+
pid=pid,
|
|
423
|
+
seq=seq,
|
|
424
|
+
stamp=time.time(),
|
|
425
|
+
fields={
|
|
426
|
+
"mod_wsgi_version": "6.0.0",
|
|
427
|
+
"python_version": "3.14.0",
|
|
428
|
+
"apache_version": "Apache/2.4.62",
|
|
429
|
+
"mpm_name": "event",
|
|
430
|
+
"hostname": socket.gethostname(),
|
|
431
|
+
"process_group": "demo",
|
|
432
|
+
"process_parent_pid": parent_pid,
|
|
433
|
+
},
|
|
434
|
+
)
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def make_stopping_sample(pid: int, seq: int, reason: str,
|
|
438
|
+
active_at_decision: int) -> Sample:
|
|
439
|
+
return Sample(
|
|
440
|
+
version=1,
|
|
441
|
+
kind=KIND_PROCESS_STOPPING,
|
|
442
|
+
pid=pid,
|
|
443
|
+
seq=seq,
|
|
444
|
+
stamp=time.time(),
|
|
445
|
+
fields={
|
|
446
|
+
"hostname": socket.gethostname(),
|
|
447
|
+
"process_group": "demo",
|
|
448
|
+
"shutdown_reason": reason,
|
|
449
|
+
"active_requests_at_decision": active_at_decision,
|
|
450
|
+
},
|
|
451
|
+
)
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def make_stopped_sample(pid: int, seq: int, reason: str,
|
|
455
|
+
uptime_s: float, lifetime_count: int,
|
|
456
|
+
active_at_exit: int) -> Sample:
|
|
457
|
+
return Sample(
|
|
458
|
+
version=1,
|
|
459
|
+
kind=KIND_PROCESS_STOPPED,
|
|
460
|
+
pid=pid,
|
|
461
|
+
seq=seq,
|
|
462
|
+
stamp=time.time(),
|
|
463
|
+
fields={
|
|
464
|
+
"hostname": socket.gethostname(),
|
|
465
|
+
"process_group": "demo",
|
|
466
|
+
"shutdown_reason": reason,
|
|
467
|
+
"process_uptime": float(uptime_s),
|
|
468
|
+
"lifetime_request_count": lifetime_count,
|
|
469
|
+
"active_requests_at_exit": active_at_exit,
|
|
470
|
+
"graceful_drain": 1 if active_at_exit == 0 else 0,
|
|
471
|
+
},
|
|
472
|
+
)
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def main(argv: list[str] | None = None) -> int:
|
|
476
|
+
ap = argparse.ArgumentParser(description=__doc__)
|
|
477
|
+
ap.add_argument("--target", required=True,
|
|
478
|
+
help="unix:/path/to/sock")
|
|
479
|
+
ap.add_argument("--processes", type=int, default=4)
|
|
480
|
+
ap.add_argument("--interval", type=float, default=1.0)
|
|
481
|
+
ap.add_argument("--duration", type=float, default=0.0,
|
|
482
|
+
help="stop after N seconds (0 = forever)")
|
|
483
|
+
args = ap.parse_args(argv)
|
|
484
|
+
|
|
485
|
+
family, addr = parse_target(args.target)
|
|
486
|
+
sock = socket.socket(family, socket.SOCK_DGRAM)
|
|
487
|
+
|
|
488
|
+
pids = [100000 + i for i in range(args.processes)]
|
|
489
|
+
seqs = [0] * args.processes
|
|
490
|
+
phases = [random.uniform(0, 2 * math.pi) for _ in range(args.processes)]
|
|
491
|
+
slot_states: list[dict] = [dict() for _ in range(args.processes)]
|
|
492
|
+
# Simulated process birth times so STOPPED can carry a plausible
|
|
493
|
+
# uptime field, and the next pid the simulator will hand out when
|
|
494
|
+
# the current one "restarts". next_pid_seed is monotonic so the
|
|
495
|
+
# consumer can tell new vs old processes apart.
|
|
496
|
+
started_at = [time.time()] * args.processes
|
|
497
|
+
next_pid_seed = 100000 + args.processes
|
|
498
|
+
parent_pid = os.getpid()
|
|
499
|
+
reason_idx = 0
|
|
500
|
+
|
|
501
|
+
# Per-process in-flight slow requests so heartbeats + completions are
|
|
502
|
+
# correlated. Keyed by a synthetic log_id so the ingester's (pid,
|
|
503
|
+
# log_id) map stays consistent across ticks.
|
|
504
|
+
in_flight: list[dict] = [dict() for _ in range(args.processes)]
|
|
505
|
+
next_log = [1] * args.processes
|
|
506
|
+
|
|
507
|
+
start = time.monotonic()
|
|
508
|
+
sent = 0
|
|
509
|
+
|
|
510
|
+
# Emit one STARTED per simulated process up-front so the UI sees the
|
|
511
|
+
# identity banner the moment the simulator connects.
|
|
512
|
+
for i in range(args.processes):
|
|
513
|
+
seqs[i] += 1
|
|
514
|
+
try:
|
|
515
|
+
sock.sendto(
|
|
516
|
+
encode(make_started_sample(pids[i], seqs[i], parent_pid)),
|
|
517
|
+
addr,
|
|
518
|
+
)
|
|
519
|
+
sent += 1
|
|
520
|
+
except OSError as e:
|
|
521
|
+
print(f"sendto failed: {e}", file=sys.stderr)
|
|
522
|
+
|
|
523
|
+
try:
|
|
524
|
+
while True:
|
|
525
|
+
if args.duration and time.monotonic() - start > args.duration:
|
|
526
|
+
break
|
|
527
|
+
for i in range(args.processes):
|
|
528
|
+
# ~1% chance per process per tick of a simulated restart
|
|
529
|
+
# so demo runs see a steady trickle of markers across
|
|
530
|
+
# all three categories. Each "restart" emits STOPPING
|
|
531
|
+
# then STOPPED, retires the pid, and STARTs a new one
|
|
532
|
+
# in its slot so the rest of the loop transparently
|
|
533
|
+
# picks up reporting the new pid.
|
|
534
|
+
if random.random() < 0.01:
|
|
535
|
+
reason = _SIM_REASONS[reason_idx % len(_SIM_REASONS)]
|
|
536
|
+
reason_idx += 1
|
|
537
|
+
active_at_decision = random.randint(0, 3)
|
|
538
|
+
seqs[i] += 1
|
|
539
|
+
try:
|
|
540
|
+
sock.sendto(encode(make_stopping_sample(
|
|
541
|
+
pids[i], seqs[i], reason, active_at_decision)),
|
|
542
|
+
addr)
|
|
543
|
+
sent += 1
|
|
544
|
+
except OSError as e:
|
|
545
|
+
print(f"sendto failed: {e}", file=sys.stderr)
|
|
546
|
+
seqs[i] += 1
|
|
547
|
+
uptime_s = max(0.0, time.time() - started_at[i])
|
|
548
|
+
try:
|
|
549
|
+
sock.sendto(encode(make_stopped_sample(
|
|
550
|
+
pids[i], seqs[i], reason, uptime_s,
|
|
551
|
+
random.randint(50, 5000), 0)),
|
|
552
|
+
addr)
|
|
553
|
+
sent += 1
|
|
554
|
+
except OSError as e:
|
|
555
|
+
print(f"sendto failed: {e}", file=sys.stderr)
|
|
556
|
+
# Retire this pid, hand out a fresh one and reset
|
|
557
|
+
# its per-process state so the new pid looks brand
|
|
558
|
+
# new on the UI side.
|
|
559
|
+
pids[i] = next_pid_seed
|
|
560
|
+
next_pid_seed += 1
|
|
561
|
+
seqs[i] = 0
|
|
562
|
+
started_at[i] = time.time()
|
|
563
|
+
slot_states[i] = dict()
|
|
564
|
+
in_flight[i] = dict()
|
|
565
|
+
next_log[i] = 1
|
|
566
|
+
seqs[i] += 1
|
|
567
|
+
try:
|
|
568
|
+
sock.sendto(encode(make_started_sample(
|
|
569
|
+
pids[i], seqs[i], parent_pid)), addr)
|
|
570
|
+
sent += 1
|
|
571
|
+
except OSError as e:
|
|
572
|
+
print(f"sendto failed: {e}", file=sys.stderr)
|
|
573
|
+
|
|
574
|
+
phases[i] += args.interval / 15.0
|
|
575
|
+
seqs[i] += 1
|
|
576
|
+
sample = make_sample(pids[i], seqs[i], phases[i], args.interval,
|
|
577
|
+
slot_state=slot_states[i])
|
|
578
|
+
try:
|
|
579
|
+
sock.sendto(encode(sample), addr)
|
|
580
|
+
sent += 1
|
|
581
|
+
except OSError as e:
|
|
582
|
+
print(f"sendto failed: {e}", file=sys.stderr)
|
|
583
|
+
|
|
584
|
+
# --- Slow requests ------------------------------------
|
|
585
|
+
# Start a new one ~30% of the time, capped at 5 per process.
|
|
586
|
+
if len(in_flight[i]) < 5 and random.random() < 0.30:
|
|
587
|
+
log_id = f"s{pids[i]}-{next_log[i]:06d}"
|
|
588
|
+
next_log[i] += 1
|
|
589
|
+
method, path = random.choice(_SLOW_PATHS)
|
|
590
|
+
in_flight[i][log_id] = {
|
|
591
|
+
"method": method,
|
|
592
|
+
"path": path,
|
|
593
|
+
"thread_id": random.randint(1, 5),
|
|
594
|
+
"start": time.time(),
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
now = time.time()
|
|
598
|
+
# Heartbeat every active.
|
|
599
|
+
for log_id, rec in list(in_flight[i].items()):
|
|
600
|
+
seqs[i] += 1
|
|
601
|
+
sample = make_slow_sample(
|
|
602
|
+
pids[i], seqs[i], state=0,
|
|
603
|
+
thread_id=rec["thread_id"], log_id=log_id,
|
|
604
|
+
method=rec["method"], path=rec["path"],
|
|
605
|
+
start_stamp=rec["start"],
|
|
606
|
+
duration=max(0.0, now - rec["start"]),
|
|
607
|
+
)
|
|
608
|
+
try:
|
|
609
|
+
sock.sendto(encode(sample), addr)
|
|
610
|
+
sent += 1
|
|
611
|
+
except OSError as e:
|
|
612
|
+
print(f"sendto failed: {e}", file=sys.stderr)
|
|
613
|
+
|
|
614
|
+
# Complete one ~25% of the time (once it's been in-flight
|
|
615
|
+
# long enough to feel plausible).
|
|
616
|
+
if in_flight[i] and random.random() < 0.25:
|
|
617
|
+
log_id = random.choice(list(in_flight[i]))
|
|
618
|
+
rec = in_flight[i].pop(log_id)
|
|
619
|
+
duration = max(0.0, now - rec["start"])
|
|
620
|
+
if duration >= 0.5: # only "complete" if >= 0.5s
|
|
621
|
+
seqs[i] += 1
|
|
622
|
+
sample = make_slow_sample(
|
|
623
|
+
pids[i], seqs[i], state=1,
|
|
624
|
+
thread_id=rec["thread_id"], log_id=log_id,
|
|
625
|
+
method=rec["method"], path=rec["path"],
|
|
626
|
+
start_stamp=rec["start"],
|
|
627
|
+
duration=duration,
|
|
628
|
+
)
|
|
629
|
+
try:
|
|
630
|
+
sock.sendto(encode(sample), addr)
|
|
631
|
+
sent += 1
|
|
632
|
+
except OSError as e:
|
|
633
|
+
print(f"sendto failed: {e}", file=sys.stderr)
|
|
634
|
+
else:
|
|
635
|
+
# Not long enough yet; put it back.
|
|
636
|
+
in_flight[i][log_id] = rec
|
|
637
|
+
|
|
638
|
+
time.sleep(args.interval)
|
|
639
|
+
except KeyboardInterrupt:
|
|
640
|
+
pass
|
|
641
|
+
finally:
|
|
642
|
+
sock.close()
|
|
643
|
+
|
|
644
|
+
print(f"sent {sent} samples")
|
|
645
|
+
return 0
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
if __name__ == "__main__":
|
|
649
|
+
sys.exit(main())
|