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.
@@ -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())