jac-loadtest 0.2.2__tar.gz → 0.2.3__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.
- {jac_loadtest-0.2.2 → jac_loadtest-0.2.3}/PKG-INFO +1 -1
- {jac_loadtest-0.2.2 → jac_loadtest-0.2.3}/jac_loadtest/cli.py +62 -7
- {jac_loadtest-0.2.2 → jac_loadtest-0.2.3}/jac_loadtest/config.py +4 -0
- {jac_loadtest-0.2.2 → jac_loadtest-0.2.3}/jac_loadtest/core/engine.py +146 -31
- {jac_loadtest-0.2.2 → jac_loadtest-0.2.3}/jac_loadtest/core/metrics.py +53 -23
- jac_loadtest-0.2.3/jac_loadtest/core/process_runner.py +195 -0
- jac_loadtest-0.2.3/jac_loadtest/output/reporter.py +307 -0
- {jac_loadtest-0.2.2 → jac_loadtest-0.2.3}/jac_loadtest/plugin.py +4 -2
- jac_loadtest-0.2.3/jac_loadtest/templates/reporter_template.html +204 -0
- {jac_loadtest-0.2.2 → jac_loadtest-0.2.3}/jac_loadtest.egg-info/PKG-INFO +1 -1
- {jac_loadtest-0.2.2 → jac_loadtest-0.2.3}/jac_loadtest.egg-info/SOURCES.txt +3 -1
- {jac_loadtest-0.2.2 → jac_loadtest-0.2.3}/pyproject.toml +4 -1
- jac_loadtest-0.2.2/jac_loadtest/output/reporter.py +0 -115
- {jac_loadtest-0.2.2 → jac_loadtest-0.2.3}/README.md +0 -0
- {jac_loadtest-0.2.2 → jac_loadtest-0.2.3}/jac_loadtest/__init__.py +0 -0
- {jac_loadtest-0.2.2 → jac_loadtest-0.2.3}/jac_loadtest/bridge/__init__.py +0 -0
- {jac_loadtest-0.2.2 → jac_loadtest-0.2.3}/jac_loadtest/bridge/auth.py +0 -0
- {jac_loadtest-0.2.2 → jac_loadtest-0.2.3}/jac_loadtest/bridge/topology.py +0 -0
- {jac_loadtest-0.2.2 → jac_loadtest-0.2.3}/jac_loadtest/core/__init__.py +0 -0
- {jac_loadtest-0.2.2 → jac_loadtest-0.2.3}/jac_loadtest/core/har_parser.py +0 -0
- {jac_loadtest-0.2.2 → jac_loadtest-0.2.3}/jac_loadtest/output/__init__.py +0 -0
- {jac_loadtest-0.2.2 → jac_loadtest-0.2.3}/jac_loadtest.egg-info/dependency_links.txt +0 -0
- {jac_loadtest-0.2.2 → jac_loadtest-0.2.3}/jac_loadtest.egg-info/entry_points.txt +0 -0
- {jac_loadtest-0.2.2 → jac_loadtest-0.2.3}/jac_loadtest.egg-info/requires.txt +0 -0
- {jac_loadtest-0.2.2 → jac_loadtest-0.2.3}/jac_loadtest.egg-info/top_level.txt +0 -0
- {jac_loadtest-0.2.2 → jac_loadtest-0.2.3}/setup.cfg +0 -0
|
@@ -15,7 +15,7 @@ def run(args: object) -> None:
|
|
|
15
15
|
from jac_loadtest.core.har_parser import parse_har
|
|
16
16
|
from jac_loadtest.core.engine import run_all_vus
|
|
17
17
|
from jac_loadtest.core.metrics import MetricsCollector
|
|
18
|
-
from jac_loadtest.output.reporter import render_console
|
|
18
|
+
from jac_loadtest.output.reporter import render_console, render_json, render_html
|
|
19
19
|
|
|
20
20
|
config = from_args(args)
|
|
21
21
|
|
|
@@ -70,17 +70,72 @@ def run(args: object) -> None:
|
|
|
70
70
|
print(f"Error: {exc}", file=sys.stderr)
|
|
71
71
|
sys.exit(2)
|
|
72
72
|
|
|
73
|
-
|
|
73
|
+
from rich.console import Console as _Console
|
|
74
|
+
_console = _Console(stderr=True)
|
|
75
|
+
|
|
74
76
|
t_start = time.time()
|
|
75
77
|
|
|
76
78
|
try:
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
79
|
+
with _console.status(f"Running load test — {config.vus} VUs..."):
|
|
80
|
+
if config.workers > 1:
|
|
81
|
+
from jac_loadtest.core.process_runner import run_multiprocess
|
|
82
|
+
metrics = run_multiprocess(entries, config, topology, auth_provider)
|
|
83
|
+
else:
|
|
84
|
+
metrics = MetricsCollector(max_samples=config.max_samples)
|
|
85
|
+
asyncio.run(
|
|
86
|
+
run_all_vus(entries, config, metrics, topology=topology, auth_provider=auth_provider)
|
|
87
|
+
)
|
|
80
88
|
except AuthenticationError as exc:
|
|
81
89
|
print(f"Error: {exc}", file=sys.stderr)
|
|
82
90
|
sys.exit(2)
|
|
83
91
|
|
|
84
92
|
duration_s = time.time() - t_start
|
|
85
|
-
stats = metrics.compute_endpoint_stats(
|
|
86
|
-
|
|
93
|
+
stats = metrics.compute_endpoint_stats()
|
|
94
|
+
snapshots = metrics.generate_timeseries(t_start)
|
|
95
|
+
completion_p50, completion_p95, completion_p99 = metrics.completion_percentiles(t_start)
|
|
96
|
+
|
|
97
|
+
fmt = config.report_format
|
|
98
|
+
|
|
99
|
+
if fmt == "json":
|
|
100
|
+
output = render_json(
|
|
101
|
+
stats, config,
|
|
102
|
+
actual_duration_s=duration_s,
|
|
103
|
+
total_rps=metrics.global_rps(duration_s),
|
|
104
|
+
snapshots=snapshots,
|
|
105
|
+
completion_p50_s=completion_p50,
|
|
106
|
+
completion_p95_s=completion_p95,
|
|
107
|
+
completion_p99_s=completion_p99,
|
|
108
|
+
)
|
|
109
|
+
if config.report_out:
|
|
110
|
+
with open(config.report_out, "w", encoding="utf-8") as fh:
|
|
111
|
+
fh.write(output)
|
|
112
|
+
print(f"JSON report written to {config.report_out}")
|
|
113
|
+
else:
|
|
114
|
+
print(output)
|
|
115
|
+
|
|
116
|
+
elif fmt == "html":
|
|
117
|
+
if not config.report_out:
|
|
118
|
+
print("Error: --report-out <path> is required for --report-format html", file=sys.stderr)
|
|
119
|
+
sys.exit(2)
|
|
120
|
+
output = render_html(
|
|
121
|
+
stats, config,
|
|
122
|
+
actual_duration_s=duration_s,
|
|
123
|
+
total_rps=metrics.global_rps(duration_s),
|
|
124
|
+
snapshots=snapshots,
|
|
125
|
+
completion_p50_s=completion_p50,
|
|
126
|
+
completion_p95_s=completion_p95,
|
|
127
|
+
completion_p99_s=completion_p99,
|
|
128
|
+
)
|
|
129
|
+
with open(config.report_out, "w", encoding="utf-8") as fh:
|
|
130
|
+
fh.write(output)
|
|
131
|
+
print(f"HTML report written to {config.report_out}", file=sys.stderr)
|
|
132
|
+
|
|
133
|
+
else:
|
|
134
|
+
render_console(
|
|
135
|
+
stats, config,
|
|
136
|
+
actual_duration_s=duration_s,
|
|
137
|
+
total_rps=metrics.global_rps(duration_s),
|
|
138
|
+
completion_p50_s=completion_p50,
|
|
139
|
+
completion_p95_s=completion_p95,
|
|
140
|
+
completion_p99_s=completion_p99,
|
|
141
|
+
)
|
|
@@ -4,6 +4,7 @@ Phase 0: dataclass with built-in defaults only.
|
|
|
4
4
|
Phase 2 will add jac.toml reading via jac_scale.config_loader.
|
|
5
5
|
"""
|
|
6
6
|
from __future__ import annotations
|
|
7
|
+
import os
|
|
7
8
|
from dataclasses import dataclass, field
|
|
8
9
|
from typing import Any
|
|
9
10
|
|
|
@@ -14,6 +15,7 @@ BUILT_IN_DEFAULTS: dict = {
|
|
|
14
15
|
"iterations": 1,
|
|
15
16
|
"ramp_up": "0s",
|
|
16
17
|
"timeout": "30s",
|
|
18
|
+
"workers": os.cpu_count() or 1,
|
|
17
19
|
"mode": "monolith",
|
|
18
20
|
"think_time": "none",
|
|
19
21
|
"think_time_scale": 1.0,
|
|
@@ -40,6 +42,7 @@ class LoadTestConfig:
|
|
|
40
42
|
iterations: int = 1
|
|
41
43
|
ramp_up: str = "0s"
|
|
42
44
|
timeout: str = "30s"
|
|
45
|
+
workers: int = 1
|
|
43
46
|
|
|
44
47
|
# Traffic
|
|
45
48
|
mode: str = "monolith"
|
|
@@ -129,6 +132,7 @@ def from_args(args: object) -> LoadTestConfig:
|
|
|
129
132
|
iterations=resolve("iterations"),
|
|
130
133
|
mode=resolve("mode"),
|
|
131
134
|
vus=resolve("vus"),
|
|
135
|
+
workers=resolve("workers"),
|
|
132
136
|
duration=resolve("duration"),
|
|
133
137
|
ramp_up=resolve("ramp_up"),
|
|
134
138
|
timeout=resolve("timeout"),
|
|
@@ -6,10 +6,12 @@ from __future__ import annotations
|
|
|
6
6
|
|
|
7
7
|
import asyncio
|
|
8
8
|
import signal
|
|
9
|
+
import socket
|
|
9
10
|
import sys
|
|
10
11
|
from typing import TYPE_CHECKING
|
|
11
12
|
|
|
12
13
|
import aiohttp
|
|
14
|
+
from aiohttp.abc import AbstractResolver as _AbstractResolver
|
|
13
15
|
|
|
14
16
|
from jac_loadtest.config import parse_duration
|
|
15
17
|
from jac_loadtest.core.metrics import RequestResult, normalize_path
|
|
@@ -22,12 +24,37 @@ if TYPE_CHECKING:
|
|
|
22
24
|
from jac_loadtest.bridge.auth import AuthProvider
|
|
23
25
|
|
|
24
26
|
|
|
27
|
+
class _PreResolvedResolver(_AbstractResolver):
|
|
28
|
+
"""Resolver that returns a pre-resolved IP for known hostnames, falling back to
|
|
29
|
+
system DNS for anything else. Injected into each worker's TCPConnector so workers
|
|
30
|
+
never issue their own DNS lookups for the target host."""
|
|
31
|
+
|
|
32
|
+
def __init__(self, host_map: dict[str, str]) -> None:
|
|
33
|
+
self._host_map = host_map
|
|
34
|
+
self._fallback = aiohttp.ThreadedResolver()
|
|
35
|
+
|
|
36
|
+
async def resolve( # type: ignore[override]
|
|
37
|
+
self, hostname: str, port: int = 0, family: socket.AddressFamily = socket.AF_INET
|
|
38
|
+
) -> list[dict]:
|
|
39
|
+
if hostname in self._host_map:
|
|
40
|
+
ip = self._host_map[hostname]
|
|
41
|
+
addr_family = socket.AF_INET6 if ":" in ip else socket.AF_INET
|
|
42
|
+
return [{"hostname": hostname, "host": ip, "port": port, "family": addr_family, "proto": 0, "flags": 0}]
|
|
43
|
+
return await self._fallback.resolve(hostname, port, family) # type: ignore[return-value]
|
|
44
|
+
|
|
45
|
+
async def close(self) -> None:
|
|
46
|
+
await self._fallback.close()
|
|
47
|
+
|
|
48
|
+
|
|
25
49
|
async def run_all_vus(
|
|
26
50
|
entries: list[HarEntry],
|
|
27
51
|
config: LoadTestConfig,
|
|
28
52
|
metrics: MetricsCollector,
|
|
29
53
|
topology: TopologyRouter | None = None,
|
|
30
54
|
auth_provider: AuthProvider | None = None,
|
|
55
|
+
vu_id_offset: int = 0,
|
|
56
|
+
pre_authed_tokens: dict[int, str] | None = None,
|
|
57
|
+
pre_resolved_hosts: dict[str, str] | None = None,
|
|
31
58
|
) -> None:
|
|
32
59
|
"""Spawn N virtual user coroutines and run until duration/iterations/stop signal."""
|
|
33
60
|
stop_requested = asyncio.Event()
|
|
@@ -44,24 +71,51 @@ async def run_all_vus(
|
|
|
44
71
|
|
|
45
72
|
signal.signal(signal.SIGINT, _on_first_sigint)
|
|
46
73
|
|
|
74
|
+
timeout = aiohttp.ClientTimeout(total=parse_duration(config.timeout))
|
|
47
75
|
ramp_up_seconds = parse_duration(config.ramp_up)
|
|
48
76
|
|
|
49
|
-
#
|
|
50
|
-
|
|
77
|
+
# Use pre-computed tokens when provided (multi-process path: auth ran in main process).
|
|
78
|
+
# Otherwise authenticate here (single-process path).
|
|
79
|
+
token_by_vu: dict[int, str] = {}
|
|
80
|
+
if pre_authed_tokens is not None:
|
|
81
|
+
token_by_vu = pre_authed_tokens
|
|
82
|
+
elif auth_provider is not None:
|
|
83
|
+
if not config.url:
|
|
84
|
+
raise ValueError("auth_provider requires --url to be set")
|
|
85
|
+
n_creds = len(auth_provider._credentials)
|
|
86
|
+
# Unique credential indices needed by this worker's VU slice.
|
|
87
|
+
cred_indices = {(vu_id_offset + i) % n_creds for i in range(config.vus)}
|
|
88
|
+
sem = asyncio.Semaphore(min(50, len(cred_indices)))
|
|
89
|
+
|
|
90
|
+
async def _do_auth(cred_idx: int) -> tuple[int, str]:
|
|
91
|
+
async with sem:
|
|
92
|
+
async with aiohttp.ClientSession(timeout=timeout) as auth_session:
|
|
93
|
+
if config.url is not None:
|
|
94
|
+
tok = await auth_provider.authenticate(cred_idx, auth_session, config.url)
|
|
95
|
+
return cred_idx, tok
|
|
96
|
+
|
|
97
|
+
auth_results: list[tuple[int, str]] = list(
|
|
98
|
+
await asyncio.gather(*[_do_auth(idx) for idx in cred_indices])
|
|
99
|
+
)
|
|
100
|
+
token_by_cred = dict(auth_results)
|
|
101
|
+
token_by_vu = {
|
|
102
|
+
vu_id_offset + i: token_by_cred[(vu_id_offset + i) % n_creds]
|
|
103
|
+
for i in range(config.vus)
|
|
104
|
+
}
|
|
51
105
|
|
|
52
106
|
tasks = [
|
|
53
107
|
asyncio.create_task(
|
|
54
108
|
_run_vu(
|
|
55
|
-
vu_id=i,
|
|
109
|
+
vu_id=vu_id_offset + i,
|
|
56
110
|
delay=(i / config.vus) * ramp_up_seconds if config.vus > 1 else 0.0,
|
|
57
111
|
entries=entries,
|
|
58
112
|
config=config,
|
|
59
113
|
metrics=metrics,
|
|
60
114
|
stop_requested=stop_requested,
|
|
61
115
|
loop=loop,
|
|
62
|
-
|
|
116
|
+
token=token_by_vu.get(vu_id_offset + i),
|
|
63
117
|
topology=topology,
|
|
64
|
-
|
|
118
|
+
pre_resolved_hosts=pre_resolved_hosts or {},
|
|
65
119
|
)
|
|
66
120
|
)
|
|
67
121
|
for i in range(config.vus)
|
|
@@ -81,40 +135,34 @@ async def _run_vu(
|
|
|
81
135
|
metrics: MetricsCollector,
|
|
82
136
|
stop_requested: asyncio.Event,
|
|
83
137
|
loop: asyncio.AbstractEventLoop,
|
|
84
|
-
|
|
138
|
+
token: str | None = None,
|
|
85
139
|
topology: TopologyRouter | None = None,
|
|
86
|
-
|
|
140
|
+
pre_resolved_hosts: dict[str, str] | None = None,
|
|
87
141
|
) -> None:
|
|
88
|
-
"""Single virtual user: wait ramp delay,
|
|
142
|
+
"""Single virtual user: wait ramp delay, then replay HAR entries with a pre-fetched token."""
|
|
89
143
|
if delay > 0:
|
|
90
144
|
await asyncio.sleep(delay)
|
|
91
145
|
|
|
92
146
|
timeout = aiohttp.ClientTimeout(total=parse_duration(config.timeout))
|
|
93
|
-
|
|
94
|
-
t_start = loop.time()
|
|
147
|
+
|
|
95
148
|
iteration = 0
|
|
96
149
|
# Warn once per unrouted path within this VU to avoid spam across iterations.
|
|
97
150
|
_warned_unrouted: set[str] = set()
|
|
98
151
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
async with (auth_semaphore or asyncio.Semaphore()):
|
|
106
|
-
token = await auth_provider.authenticate(vu_id, session, config.url)
|
|
107
|
-
|
|
152
|
+
connector = (
|
|
153
|
+
aiohttp.TCPConnector(resolver=_PreResolvedResolver(pre_resolved_hosts))
|
|
154
|
+
if pre_resolved_hosts
|
|
155
|
+
else aiohttp.TCPConnector()
|
|
156
|
+
)
|
|
157
|
+
async with aiohttp.ClientSession(connector=connector, timeout=timeout) as session:
|
|
108
158
|
while not stop_requested.is_set():
|
|
109
|
-
if loop.time() - t_start >= duration_seconds:
|
|
110
|
-
break
|
|
111
159
|
if config.iterations is not None and iteration >= config.iterations:
|
|
112
160
|
break
|
|
113
161
|
|
|
114
162
|
for entry in entries:
|
|
115
163
|
if stop_requested.is_set():
|
|
116
164
|
break
|
|
117
|
-
# Skip the HAR-recorded login; auth is handled by
|
|
165
|
+
# Skip the HAR-recorded login; auth is handled by pre-fetched token.
|
|
118
166
|
if entry.is_login and token is not None:
|
|
119
167
|
continue
|
|
120
168
|
result = await _send_request(
|
|
@@ -127,8 +175,6 @@ async def _run_vu(
|
|
|
127
175
|
topology=topology,
|
|
128
176
|
)
|
|
129
177
|
if result is None:
|
|
130
|
-
# topology.resolve() found no route (e.g. /static/* fetch entries
|
|
131
|
-
# that look like API calls but have no matching service prefix).
|
|
132
178
|
from urllib.parse import urlparse as _up
|
|
133
179
|
path = _up(entry.url).path
|
|
134
180
|
if path not in _warned_unrouted:
|
|
@@ -162,8 +208,6 @@ async def _send_request(
|
|
|
162
208
|
if token:
|
|
163
209
|
headers["Authorization"] = f"Bearer {token}"
|
|
164
210
|
|
|
165
|
-
# Resolve the final request URL and service label via topology.
|
|
166
|
-
# topology=None means monolith mode: use entry.url as-is, label "monolith".
|
|
167
211
|
if topology is not None:
|
|
168
212
|
try:
|
|
169
213
|
request_url, service_name = topology.resolve(entry.url)
|
|
@@ -172,9 +216,10 @@ async def _send_request(
|
|
|
172
216
|
else:
|
|
173
217
|
request_url, service_name = entry.url, "monolith"
|
|
174
218
|
|
|
175
|
-
|
|
219
|
+
import time as _time
|
|
176
220
|
endpoint = normalize_path(entry.url)
|
|
177
|
-
t0 = loop.time()
|
|
221
|
+
t0 = loop.time() # high-res clock for latency measurement only
|
|
222
|
+
ts = _time.time() # wall-clock for cross-process timestamp comparison
|
|
178
223
|
|
|
179
224
|
try:
|
|
180
225
|
async with session.request(
|
|
@@ -192,7 +237,7 @@ async def _send_request(
|
|
|
192
237
|
status=resp.status,
|
|
193
238
|
latency_ms=latency_ms,
|
|
194
239
|
bytes_received=len(body),
|
|
195
|
-
timestamp=
|
|
240
|
+
timestamp=ts,
|
|
196
241
|
vu_id=vu_id,
|
|
197
242
|
error_type=None,
|
|
198
243
|
occurrence=entry.occurrence,
|
|
@@ -206,13 +251,41 @@ async def _send_request(
|
|
|
206
251
|
status=0,
|
|
207
252
|
latency_ms=parse_duration(config.timeout) * 1000,
|
|
208
253
|
bytes_received=0,
|
|
209
|
-
timestamp=
|
|
254
|
+
timestamp=ts,
|
|
210
255
|
vu_id=vu_id,
|
|
211
256
|
error_type="TIMEOUT",
|
|
212
257
|
occurrence=entry.occurrence,
|
|
213
258
|
total_occurrences=entry.total_occurrences,
|
|
214
259
|
)
|
|
215
260
|
|
|
261
|
+
except aiohttp.ClientConnectorDNSError:
|
|
262
|
+
return RequestResult(
|
|
263
|
+
endpoint=endpoint,
|
|
264
|
+
service=service_name,
|
|
265
|
+
status=0,
|
|
266
|
+
latency_ms=0.0,
|
|
267
|
+
bytes_received=0,
|
|
268
|
+
timestamp=ts,
|
|
269
|
+
vu_id=vu_id,
|
|
270
|
+
error_type="DNS_ERROR",
|
|
271
|
+
occurrence=entry.occurrence,
|
|
272
|
+
total_occurrences=entry.total_occurrences,
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
except aiohttp.ClientSSLError:
|
|
276
|
+
return RequestResult(
|
|
277
|
+
endpoint=endpoint,
|
|
278
|
+
service=service_name,
|
|
279
|
+
status=0,
|
|
280
|
+
latency_ms=0.0,
|
|
281
|
+
bytes_received=0,
|
|
282
|
+
timestamp=ts,
|
|
283
|
+
vu_id=vu_id,
|
|
284
|
+
error_type="SSL_ERROR",
|
|
285
|
+
occurrence=entry.occurrence,
|
|
286
|
+
total_occurrences=entry.total_occurrences,
|
|
287
|
+
)
|
|
288
|
+
|
|
216
289
|
except aiohttp.ClientConnectorError:
|
|
217
290
|
return RequestResult(
|
|
218
291
|
endpoint=endpoint,
|
|
@@ -220,9 +293,51 @@ async def _send_request(
|
|
|
220
293
|
status=0,
|
|
221
294
|
latency_ms=0.0,
|
|
222
295
|
bytes_received=0,
|
|
223
|
-
timestamp=
|
|
296
|
+
timestamp=ts,
|
|
224
297
|
vu_id=vu_id,
|
|
225
298
|
error_type="CONNECTION_REFUSED",
|
|
226
299
|
occurrence=entry.occurrence,
|
|
227
300
|
total_occurrences=entry.total_occurrences,
|
|
228
301
|
)
|
|
302
|
+
|
|
303
|
+
except aiohttp.ServerDisconnectedError:
|
|
304
|
+
return RequestResult(
|
|
305
|
+
endpoint=endpoint,
|
|
306
|
+
service=service_name,
|
|
307
|
+
status=0,
|
|
308
|
+
latency_ms=(loop.time() - t0) * 1000,
|
|
309
|
+
bytes_received=0,
|
|
310
|
+
timestamp=ts,
|
|
311
|
+
vu_id=vu_id,
|
|
312
|
+
error_type="SERVER_DISCONNECTED",
|
|
313
|
+
occurrence=entry.occurrence,
|
|
314
|
+
total_occurrences=entry.total_occurrences,
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
except aiohttp.ClientOSError:
|
|
318
|
+
return RequestResult(
|
|
319
|
+
endpoint=endpoint,
|
|
320
|
+
service=service_name,
|
|
321
|
+
status=0,
|
|
322
|
+
latency_ms=(loop.time() - t0) * 1000,
|
|
323
|
+
bytes_received=0,
|
|
324
|
+
timestamp=ts,
|
|
325
|
+
vu_id=vu_id,
|
|
326
|
+
error_type="CONNECTION_RESET",
|
|
327
|
+
occurrence=entry.occurrence,
|
|
328
|
+
total_occurrences=entry.total_occurrences,
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
except Exception as e:
|
|
332
|
+
return RequestResult(
|
|
333
|
+
endpoint=endpoint,
|
|
334
|
+
service=service_name,
|
|
335
|
+
status=0,
|
|
336
|
+
latency_ms=(loop.time() - t0) * 1000,
|
|
337
|
+
bytes_received=0,
|
|
338
|
+
timestamp=ts,
|
|
339
|
+
vu_id=vu_id,
|
|
340
|
+
error_type=str(e).upper() or type(e).__name__.upper(),
|
|
341
|
+
occurrence=entry.occurrence,
|
|
342
|
+
total_occurrences=entry.total_occurrences,
|
|
343
|
+
)
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
Three-layer storage (Phase 4):
|
|
4
4
|
Layer 1 — total_count (always accurate RPS)
|
|
5
5
|
Layer 2 — deque(maxlen=max_samples) of RequestResult (percentiles)
|
|
6
|
-
Layer 3 — list[StatsSnapshot]
|
|
6
|
+
Layer 3 — list[StatsSnapshot] generated post-run by binning samples into 10s intervals
|
|
7
7
|
|
|
8
8
|
Phase 0: dataclasses only.
|
|
9
9
|
"""
|
|
@@ -22,7 +22,7 @@ class RequestResult:
|
|
|
22
22
|
bytes_received: int
|
|
23
23
|
timestamp: float
|
|
24
24
|
vu_id: int
|
|
25
|
-
error_type: str | None # None | "TIMEOUT" | "CONNECTION_REFUSED" | "DNS_ERROR" | "SSL_ERROR"
|
|
25
|
+
error_type: str | None # None | "TIMEOUT" | "CONNECTION_REFUSED" | "DNS_ERROR" | "SSL_ERROR" | "SERVER_DISCONNECTED" | "CONNECTION_RESET"
|
|
26
26
|
occurrence: int = 1
|
|
27
27
|
total_occurrences: int = 1
|
|
28
28
|
|
|
@@ -41,7 +41,6 @@ class EndpointStats:
|
|
|
41
41
|
p50_ms: float
|
|
42
42
|
p95_ms: float
|
|
43
43
|
p99_ms: float
|
|
44
|
-
rps: float
|
|
45
44
|
error_breakdown: dict[str, int] = field(default_factory=dict)
|
|
46
45
|
|
|
47
46
|
|
|
@@ -53,6 +52,7 @@ class StatsSnapshot:
|
|
|
53
52
|
p99_ms: float
|
|
54
53
|
rps: float
|
|
55
54
|
error_rate_pct: float
|
|
55
|
+
total_requests: int = 0
|
|
56
56
|
|
|
57
57
|
|
|
58
58
|
def percentile(latencies: list[float], p: float) -> float:
|
|
@@ -88,20 +88,22 @@ class MetricsCollector:
|
|
|
88
88
|
def __init__(self, max_samples: int = 1_000_000) -> None:
|
|
89
89
|
self.total_count: int = 0
|
|
90
90
|
self._samples: deque[RequestResult] = deque(maxlen=max_samples)
|
|
91
|
-
self._snapshots: list[StatsSnapshot] = []
|
|
92
91
|
|
|
93
92
|
def record(self, result: RequestResult) -> None:
|
|
94
93
|
self.total_count += 1
|
|
95
94
|
self._samples.append(result)
|
|
96
95
|
|
|
97
|
-
def
|
|
96
|
+
def global_rps(self, duration_seconds: float) -> float:
|
|
97
|
+
"""Return total requests per second across all endpoints."""
|
|
98
|
+
return self.total_count / max(duration_seconds, 0.001)
|
|
99
|
+
|
|
100
|
+
def compute_endpoint_stats(self) -> list[EndpointStats]:
|
|
98
101
|
"""Aggregate per-endpoint stats from collected samples."""
|
|
99
102
|
groups: dict[str, list[RequestResult]] = {}
|
|
100
103
|
for result in self._samples:
|
|
101
104
|
groups.setdefault(result.endpoint, []).append(result)
|
|
102
105
|
|
|
103
106
|
stats: list[EndpointStats] = []
|
|
104
|
-
safe_duration = max(duration_seconds, 0.001)
|
|
105
107
|
|
|
106
108
|
for endpoint, results in groups.items():
|
|
107
109
|
latencies = [r.latency_ms for r in results]
|
|
@@ -142,29 +144,57 @@ class MetricsCollector:
|
|
|
142
144
|
p50_ms=percentile(latencies, 50),
|
|
143
145
|
p95_ms=percentile(latencies, 95),
|
|
144
146
|
p99_ms=percentile(latencies, 99),
|
|
145
|
-
rps=self.total_count / safe_duration,
|
|
146
147
|
error_breakdown=error_breakdown,
|
|
147
148
|
)
|
|
148
149
|
)
|
|
149
150
|
|
|
150
151
|
return stats
|
|
151
152
|
|
|
152
|
-
def
|
|
153
|
-
"""
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
153
|
+
def completion_percentiles(self, t_start: float) -> tuple[float, float, float]:
|
|
154
|
+
"""Return (p50, p95, p99) elapsed seconds from t_start at which requests completed.
|
|
155
|
+
|
|
156
|
+
Each value answers: "by this elapsed time, that fraction of all requests had finished."
|
|
157
|
+
Completion time per request = (timestamp - t_start) + latency_ms / 1000.
|
|
158
|
+
"""
|
|
159
|
+
times = [
|
|
160
|
+
(r.timestamp - t_start) + r.latency_ms / 1000.0
|
|
161
|
+
for r in self._samples
|
|
162
|
+
]
|
|
163
|
+
return percentile(times, 50), percentile(times, 95), percentile(times, 99)
|
|
164
|
+
|
|
165
|
+
def generate_timeseries(self, t_start: float, interval: float = 10.0) -> list[StatsSnapshot]:
|
|
166
|
+
"""Bin all samples into `interval`-second buckets and return one StatsSnapshot per bucket.
|
|
167
|
+
|
|
168
|
+
Uses wall-clock timestamps stored on each RequestResult so samples from all
|
|
169
|
+
worker processes are comparable on the same timeline.
|
|
170
|
+
"""
|
|
171
|
+
if not self._samples:
|
|
172
|
+
return []
|
|
173
|
+
|
|
174
|
+
buckets: dict[int, list[RequestResult]] = {}
|
|
175
|
+
for r in self._samples:
|
|
176
|
+
bucket = int((r.timestamp - t_start) / interval)
|
|
177
|
+
if bucket >= 0:
|
|
178
|
+
buckets.setdefault(bucket, []).append(r)
|
|
179
|
+
|
|
180
|
+
snapshots: list[StatsSnapshot] = []
|
|
181
|
+
running_total = 0
|
|
182
|
+
for bucket_idx in sorted(buckets.keys()):
|
|
183
|
+
results = buckets[bucket_idx]
|
|
184
|
+
running_total += len(results)
|
|
185
|
+
latencies = [r.latency_ms for r in results]
|
|
186
|
+
errors = sum(
|
|
187
|
+
1 for r in results
|
|
188
|
+
if r.error_type is not None or not (200 <= r.status < 300)
|
|
189
|
+
)
|
|
190
|
+
snapshots.append(StatsSnapshot(
|
|
191
|
+
timestamp=(bucket_idx + 1) * interval,
|
|
164
192
|
p50_ms=percentile(latencies, 50),
|
|
165
193
|
p95_ms=percentile(latencies, 95),
|
|
166
194
|
p99_ms=percentile(latencies, 99),
|
|
167
|
-
rps=
|
|
168
|
-
error_rate_pct=(
|
|
169
|
-
|
|
170
|
-
|
|
195
|
+
rps=running_total / ((bucket_idx + 1) * interval),
|
|
196
|
+
error_rate_pct=(errors / len(results) * 100.0) if results else 0.0,
|
|
197
|
+
total_requests=running_total,
|
|
198
|
+
))
|
|
199
|
+
|
|
200
|
+
return snapshots
|