jac-loadtest 0.2.1__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.1 → jac_loadtest-0.2.3}/PKG-INFO +1 -1
- {jac_loadtest-0.2.1 → jac_loadtest-0.2.3}/jac_loadtest/bridge/topology.py +3 -1
- {jac_loadtest-0.2.1 → jac_loadtest-0.2.3}/jac_loadtest/cli.py +62 -7
- {jac_loadtest-0.2.1 → jac_loadtest-0.2.3}/jac_loadtest/config.py +8 -3
- jac_loadtest-0.2.3/jac_loadtest/core/engine.py +343 -0
- {jac_loadtest-0.2.1 → jac_loadtest-0.2.3}/jac_loadtest/core/har_parser.py +55 -9
- {jac_loadtest-0.2.1 → jac_loadtest-0.2.3}/jac_loadtest/core/metrics.py +61 -25
- 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.1 → 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.1 → jac_loadtest-0.2.3}/jac_loadtest.egg-info/PKG-INFO +1 -1
- {jac_loadtest-0.2.1 → jac_loadtest-0.2.3}/jac_loadtest.egg-info/SOURCES.txt +3 -1
- {jac_loadtest-0.2.1 → jac_loadtest-0.2.3}/pyproject.toml +4 -1
- jac_loadtest-0.2.1/jac_loadtest/core/engine.py +0 -212
- jac_loadtest-0.2.1/jac_loadtest/output/reporter.py +0 -115
- {jac_loadtest-0.2.1 → jac_loadtest-0.2.3}/README.md +0 -0
- {jac_loadtest-0.2.1 → jac_loadtest-0.2.3}/jac_loadtest/__init__.py +0 -0
- {jac_loadtest-0.2.1 → jac_loadtest-0.2.3}/jac_loadtest/bridge/__init__.py +0 -0
- {jac_loadtest-0.2.1 → jac_loadtest-0.2.3}/jac_loadtest/bridge/auth.py +0 -0
- {jac_loadtest-0.2.1 → jac_loadtest-0.2.3}/jac_loadtest/core/__init__.py +0 -0
- {jac_loadtest-0.2.1 → jac_loadtest-0.2.3}/jac_loadtest/output/__init__.py +0 -0
- {jac_loadtest-0.2.1 → jac_loadtest-0.2.3}/jac_loadtest.egg-info/dependency_links.txt +0 -0
- {jac_loadtest-0.2.1 → jac_loadtest-0.2.3}/jac_loadtest.egg-info/entry_points.txt +0 -0
- {jac_loadtest-0.2.1 → jac_loadtest-0.2.3}/jac_loadtest.egg-info/requires.txt +0 -0
- {jac_loadtest-0.2.1 → jac_loadtest-0.2.3}/jac_loadtest.egg-info/top_level.txt +0 -0
- {jac_loadtest-0.2.1 → jac_loadtest-0.2.3}/setup.cfg +0 -0
|
@@ -104,6 +104,8 @@ class TopologyRouter:
|
|
|
104
104
|
def _build_microservice(cls, config: LoadTestConfig) -> TopologyRouter:
|
|
105
105
|
toml_routes = _load_toml_routes() # service_name → prefix; {} if unavailable
|
|
106
106
|
|
|
107
|
+
routes: list[ServiceRoute]
|
|
108
|
+
|
|
107
109
|
if config.services_map:
|
|
108
110
|
try:
|
|
109
111
|
services_json: dict[str, str] = json.loads(config.services_map)
|
|
@@ -129,7 +131,7 @@ class TopologyRouter:
|
|
|
129
131
|
"[plugins.scale.microservices.routes] in jac.toml. Neither was found."
|
|
130
132
|
)
|
|
131
133
|
|
|
132
|
-
routes
|
|
134
|
+
routes = []
|
|
133
135
|
missing: list[str] = []
|
|
134
136
|
for name, prefix in toml_routes.items():
|
|
135
137
|
env_var = f"JAC_SV_{name.upper()}_URL"
|
|
@@ -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,15 +4,18 @@ 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
|
|
9
|
+
from typing import Any
|
|
8
10
|
|
|
9
11
|
|
|
10
12
|
BUILT_IN_DEFAULTS: dict = {
|
|
11
13
|
"vus": 1,
|
|
12
14
|
"duration": "30s",
|
|
13
|
-
"iterations":
|
|
15
|
+
"iterations": 1,
|
|
14
16
|
"ramp_up": "0s",
|
|
15
17
|
"timeout": "30s",
|
|
18
|
+
"workers": os.cpu_count() or 1,
|
|
16
19
|
"mode": "monolith",
|
|
17
20
|
"think_time": "none",
|
|
18
21
|
"think_time_scale": 1.0,
|
|
@@ -36,9 +39,10 @@ class LoadTestConfig:
|
|
|
36
39
|
# Load shape
|
|
37
40
|
vus: int = 1
|
|
38
41
|
duration: str = "30s"
|
|
39
|
-
iterations: int
|
|
42
|
+
iterations: int = 1
|
|
40
43
|
ramp_up: str = "0s"
|
|
41
44
|
timeout: str = "30s"
|
|
45
|
+
workers: int = 1
|
|
42
46
|
|
|
43
47
|
# Traffic
|
|
44
48
|
mode: str = "monolith"
|
|
@@ -107,7 +111,7 @@ def from_args(args: object) -> LoadTestConfig:
|
|
|
107
111
|
|
|
108
112
|
# For toml-sourced fields, use CLI value if provided (not None), else toml value,
|
|
109
113
|
# else built-in default.
|
|
110
|
-
def resolve(name: str) ->
|
|
114
|
+
def resolve(name: str) -> Any:
|
|
111
115
|
cli_val = getattr(args, name, None)
|
|
112
116
|
if cli_val is not None:
|
|
113
117
|
return cli_val
|
|
@@ -128,6 +132,7 @@ def from_args(args: object) -> LoadTestConfig:
|
|
|
128
132
|
iterations=resolve("iterations"),
|
|
129
133
|
mode=resolve("mode"),
|
|
130
134
|
vus=resolve("vus"),
|
|
135
|
+
workers=resolve("workers"),
|
|
131
136
|
duration=resolve("duration"),
|
|
132
137
|
ramp_up=resolve("ramp_up"),
|
|
133
138
|
timeout=resolve("timeout"),
|
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
"""asyncio VU pool: ramp-up, duration/iteration control, graceful shutdown.
|
|
2
|
+
|
|
3
|
+
core/ has zero knowledge of jac-scale internals.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import asyncio
|
|
8
|
+
import signal
|
|
9
|
+
import socket
|
|
10
|
+
import sys
|
|
11
|
+
from typing import TYPE_CHECKING
|
|
12
|
+
|
|
13
|
+
import aiohttp
|
|
14
|
+
from aiohttp.abc import AbstractResolver as _AbstractResolver
|
|
15
|
+
|
|
16
|
+
from jac_loadtest.config import parse_duration
|
|
17
|
+
from jac_loadtest.core.metrics import RequestResult, normalize_path
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from jac_loadtest.core.har_parser import HarEntry
|
|
21
|
+
from jac_loadtest.core.metrics import MetricsCollector
|
|
22
|
+
from jac_loadtest.config import LoadTestConfig
|
|
23
|
+
from jac_loadtest.bridge.topology import TopologyRouter
|
|
24
|
+
from jac_loadtest.bridge.auth import AuthProvider
|
|
25
|
+
|
|
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
|
+
|
|
49
|
+
async def run_all_vus(
|
|
50
|
+
entries: list[HarEntry],
|
|
51
|
+
config: LoadTestConfig,
|
|
52
|
+
metrics: MetricsCollector,
|
|
53
|
+
topology: TopologyRouter | None = None,
|
|
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,
|
|
58
|
+
) -> None:
|
|
59
|
+
"""Spawn N virtual user coroutines and run until duration/iterations/stop signal."""
|
|
60
|
+
stop_requested = asyncio.Event()
|
|
61
|
+
loop = asyncio.get_event_loop()
|
|
62
|
+
|
|
63
|
+
original_sigint = signal.getsignal(signal.SIGINT)
|
|
64
|
+
|
|
65
|
+
def _on_second_sigint(sig: int, frame: object) -> None:
|
|
66
|
+
sys.exit(130)
|
|
67
|
+
|
|
68
|
+
def _on_first_sigint(sig: int, frame: object) -> None:
|
|
69
|
+
stop_requested.set()
|
|
70
|
+
signal.signal(signal.SIGINT, _on_second_sigint)
|
|
71
|
+
|
|
72
|
+
signal.signal(signal.SIGINT, _on_first_sigint)
|
|
73
|
+
|
|
74
|
+
timeout = aiohttp.ClientTimeout(total=parse_duration(config.timeout))
|
|
75
|
+
ramp_up_seconds = parse_duration(config.ramp_up)
|
|
76
|
+
|
|
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
|
+
}
|
|
105
|
+
|
|
106
|
+
tasks = [
|
|
107
|
+
asyncio.create_task(
|
|
108
|
+
_run_vu(
|
|
109
|
+
vu_id=vu_id_offset + i,
|
|
110
|
+
delay=(i / config.vus) * ramp_up_seconds if config.vus > 1 else 0.0,
|
|
111
|
+
entries=entries,
|
|
112
|
+
config=config,
|
|
113
|
+
metrics=metrics,
|
|
114
|
+
stop_requested=stop_requested,
|
|
115
|
+
loop=loop,
|
|
116
|
+
token=token_by_vu.get(vu_id_offset + i),
|
|
117
|
+
topology=topology,
|
|
118
|
+
pre_resolved_hosts=pre_resolved_hosts or {},
|
|
119
|
+
)
|
|
120
|
+
)
|
|
121
|
+
for i in range(config.vus)
|
|
122
|
+
]
|
|
123
|
+
|
|
124
|
+
try:
|
|
125
|
+
await asyncio.gather(*tasks)
|
|
126
|
+
finally:
|
|
127
|
+
signal.signal(signal.SIGINT, original_sigint)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
async def _run_vu(
|
|
131
|
+
vu_id: int,
|
|
132
|
+
delay: float,
|
|
133
|
+
entries: list[HarEntry],
|
|
134
|
+
config: LoadTestConfig,
|
|
135
|
+
metrics: MetricsCollector,
|
|
136
|
+
stop_requested: asyncio.Event,
|
|
137
|
+
loop: asyncio.AbstractEventLoop,
|
|
138
|
+
token: str | None = None,
|
|
139
|
+
topology: TopologyRouter | None = None,
|
|
140
|
+
pre_resolved_hosts: dict[str, str] | None = None,
|
|
141
|
+
) -> None:
|
|
142
|
+
"""Single virtual user: wait ramp delay, then replay HAR entries with a pre-fetched token."""
|
|
143
|
+
if delay > 0:
|
|
144
|
+
await asyncio.sleep(delay)
|
|
145
|
+
|
|
146
|
+
timeout = aiohttp.ClientTimeout(total=parse_duration(config.timeout))
|
|
147
|
+
|
|
148
|
+
iteration = 0
|
|
149
|
+
# Warn once per unrouted path within this VU to avoid spam across iterations.
|
|
150
|
+
_warned_unrouted: set[str] = set()
|
|
151
|
+
|
|
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:
|
|
158
|
+
while not stop_requested.is_set():
|
|
159
|
+
if config.iterations is not None and iteration >= config.iterations:
|
|
160
|
+
break
|
|
161
|
+
|
|
162
|
+
for entry in entries:
|
|
163
|
+
if stop_requested.is_set():
|
|
164
|
+
break
|
|
165
|
+
# Skip the HAR-recorded login; auth is handled by pre-fetched token.
|
|
166
|
+
if entry.is_login and token is not None:
|
|
167
|
+
continue
|
|
168
|
+
result = await _send_request(
|
|
169
|
+
session=session,
|
|
170
|
+
entry=entry,
|
|
171
|
+
vu_id=vu_id,
|
|
172
|
+
config=config,
|
|
173
|
+
loop=loop,
|
|
174
|
+
token=token,
|
|
175
|
+
topology=topology,
|
|
176
|
+
)
|
|
177
|
+
if result is None:
|
|
178
|
+
from urllib.parse import urlparse as _up
|
|
179
|
+
path = _up(entry.url).path
|
|
180
|
+
if path not in _warned_unrouted:
|
|
181
|
+
print(
|
|
182
|
+
f"\n\033[33mWarning: no route for '{path}' — skipping. "
|
|
183
|
+
"Add a matching prefix to --services-map or set --url as fallback.\033[0m",
|
|
184
|
+
file=sys.stderr,
|
|
185
|
+
)
|
|
186
|
+
_warned_unrouted.add(path)
|
|
187
|
+
continue
|
|
188
|
+
metrics.record(result)
|
|
189
|
+
if config.think_time == "real" and entry.think_time_ms > 0:
|
|
190
|
+
await asyncio.sleep(
|
|
191
|
+
entry.think_time_ms / 1000.0 * config.think_time_scale
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
iteration += 1
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
async def _send_request(
|
|
198
|
+
session: aiohttp.ClientSession,
|
|
199
|
+
entry: HarEntry,
|
|
200
|
+
vu_id: int,
|
|
201
|
+
config: LoadTestConfig,
|
|
202
|
+
loop: asyncio.AbstractEventLoop,
|
|
203
|
+
token: str | None = None,
|
|
204
|
+
topology: TopologyRouter | None = None,
|
|
205
|
+
) -> RequestResult | None:
|
|
206
|
+
"""Send one HTTP request and return a RequestResult, or None if no route exists."""
|
|
207
|
+
headers = dict(entry.headers)
|
|
208
|
+
if token:
|
|
209
|
+
headers["Authorization"] = f"Bearer {token}"
|
|
210
|
+
|
|
211
|
+
if topology is not None:
|
|
212
|
+
try:
|
|
213
|
+
request_url, service_name = topology.resolve(entry.url)
|
|
214
|
+
except ValueError:
|
|
215
|
+
return None # caller warns and skips
|
|
216
|
+
else:
|
|
217
|
+
request_url, service_name = entry.url, "monolith"
|
|
218
|
+
|
|
219
|
+
import time as _time
|
|
220
|
+
endpoint = normalize_path(entry.url)
|
|
221
|
+
t0 = loop.time() # high-res clock for latency measurement only
|
|
222
|
+
ts = _time.time() # wall-clock for cross-process timestamp comparison
|
|
223
|
+
|
|
224
|
+
try:
|
|
225
|
+
async with session.request(
|
|
226
|
+
method=entry.method,
|
|
227
|
+
url=request_url,
|
|
228
|
+
headers=headers,
|
|
229
|
+
data=entry.body,
|
|
230
|
+
allow_redirects=False,
|
|
231
|
+
) as resp:
|
|
232
|
+
body = await resp.read()
|
|
233
|
+
latency_ms = (loop.time() - t0) * 1000
|
|
234
|
+
return RequestResult(
|
|
235
|
+
endpoint=endpoint,
|
|
236
|
+
service=service_name,
|
|
237
|
+
status=resp.status,
|
|
238
|
+
latency_ms=latency_ms,
|
|
239
|
+
bytes_received=len(body),
|
|
240
|
+
timestamp=ts,
|
|
241
|
+
vu_id=vu_id,
|
|
242
|
+
error_type=None,
|
|
243
|
+
occurrence=entry.occurrence,
|
|
244
|
+
total_occurrences=entry.total_occurrences,
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
except asyncio.TimeoutError:
|
|
248
|
+
return RequestResult(
|
|
249
|
+
endpoint=endpoint,
|
|
250
|
+
service=service_name,
|
|
251
|
+
status=0,
|
|
252
|
+
latency_ms=parse_duration(config.timeout) * 1000,
|
|
253
|
+
bytes_received=0,
|
|
254
|
+
timestamp=ts,
|
|
255
|
+
vu_id=vu_id,
|
|
256
|
+
error_type="TIMEOUT",
|
|
257
|
+
occurrence=entry.occurrence,
|
|
258
|
+
total_occurrences=entry.total_occurrences,
|
|
259
|
+
)
|
|
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
|
+
|
|
289
|
+
except aiohttp.ClientConnectorError:
|
|
290
|
+
return RequestResult(
|
|
291
|
+
endpoint=endpoint,
|
|
292
|
+
service=service_name,
|
|
293
|
+
status=0,
|
|
294
|
+
latency_ms=0.0,
|
|
295
|
+
bytes_received=0,
|
|
296
|
+
timestamp=ts,
|
|
297
|
+
vu_id=vu_id,
|
|
298
|
+
error_type="CONNECTION_REFUSED",
|
|
299
|
+
occurrence=entry.occurrence,
|
|
300
|
+
total_occurrences=entry.total_occurrences,
|
|
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
|
+
)
|
|
@@ -49,6 +49,8 @@ class HarEntry:
|
|
|
49
49
|
think_time_ms: float
|
|
50
50
|
is_login: bool
|
|
51
51
|
original_url: str
|
|
52
|
+
occurrence: int = 0
|
|
53
|
+
total_occurrences: int = 0
|
|
52
54
|
|
|
53
55
|
|
|
54
56
|
def _origin(url: str) -> str:
|
|
@@ -81,6 +83,22 @@ def _is_unsupported_type(entry: dict) -> bool:
|
|
|
81
83
|
return url.startswith(("ws://", "wss://"))
|
|
82
84
|
|
|
83
85
|
|
|
86
|
+
def _has_missing_body(method: str, raw_headers: list[dict], post_data: dict | None) -> bool:
|
|
87
|
+
"""Return True if a request should have a body but none was captured in the HAR."""
|
|
88
|
+
if method.upper() not in ("POST", "PUT", "PATCH"):
|
|
89
|
+
return False
|
|
90
|
+
content_length = next(
|
|
91
|
+
(h["value"] for h in raw_headers if h.get("name", "").lower() == "content-length"),
|
|
92
|
+
"0",
|
|
93
|
+
)
|
|
94
|
+
try:
|
|
95
|
+
has_content = int(content_length) > 0
|
|
96
|
+
except ValueError:
|
|
97
|
+
has_content = False
|
|
98
|
+
body_text = (post_data or {}).get("text")
|
|
99
|
+
return has_content and not body_text
|
|
100
|
+
|
|
101
|
+
|
|
84
102
|
def _has_cache_buster(url: str) -> bool:
|
|
85
103
|
"""Return True if the URL contains a stale cache-busting timestamp parameter."""
|
|
86
104
|
qs = parse_qs(urlparse(url).query)
|
|
@@ -125,6 +143,7 @@ def parse_har(
|
|
|
125
143
|
result: list[HarEntry] = []
|
|
126
144
|
warned_unsupported = False
|
|
127
145
|
warned_cache_buster = False
|
|
146
|
+
warned_missing_body = False
|
|
128
147
|
|
|
129
148
|
for entry in raw_entries:
|
|
130
149
|
req = entry["request"]
|
|
@@ -135,9 +154,9 @@ def parse_har(
|
|
|
135
154
|
if _is_unsupported_type(entry):
|
|
136
155
|
if not warned_unsupported:
|
|
137
156
|
print(
|
|
138
|
-
"
|
|
157
|
+
"\n\033[33mWarning: HAR contains WebSocket, SSE, or non-API entries "
|
|
139
158
|
"(websocket, eventsource, document, etc.). "
|
|
140
|
-
"These are skipped automatically
|
|
159
|
+
"These are skipped automatically.\033[0m",
|
|
141
160
|
file=sys.stderr,
|
|
142
161
|
)
|
|
143
162
|
warned_unsupported = True
|
|
@@ -148,9 +167,9 @@ def parse_har(
|
|
|
148
167
|
if _has_cache_buster(original_url):
|
|
149
168
|
if not warned_cache_buster:
|
|
150
169
|
print(
|
|
151
|
-
"
|
|
170
|
+
"\n\033[33mWarning: HAR contains URLs with cache-busting timestamp "
|
|
152
171
|
"parameters (e.g. ?_=<timestamp>). These entries are skipped — "
|
|
153
|
-
"the stale timestamp causes the server to reject the request
|
|
172
|
+
"the stale timestamp causes the server to reject the request.\033[0m",
|
|
154
173
|
file=sys.stderr,
|
|
155
174
|
)
|
|
156
175
|
warned_cache_buster = True
|
|
@@ -164,7 +183,21 @@ def parse_har(
|
|
|
164
183
|
else:
|
|
165
184
|
rewritten_url = original_url # keep recorded URL; topology handles routing
|
|
166
185
|
|
|
167
|
-
|
|
186
|
+
raw_headers = req.get("headers", [])
|
|
187
|
+
|
|
188
|
+
if _has_missing_body(req["method"], raw_headers, req.get("postData")):
|
|
189
|
+
if not warned_missing_body:
|
|
190
|
+
print(
|
|
191
|
+
"\n\033[33mWarning: HAR contains POST/PUT/PATCH entries where the request body "
|
|
192
|
+
"was not captured (postData missing despite non-zero Content-Length). "
|
|
193
|
+
"These entries are skipped — replaying them without a body would cause "
|
|
194
|
+
"422 errors. Re-record the HAR to capture the full request body.\033[0m",
|
|
195
|
+
file=sys.stderr,
|
|
196
|
+
)
|
|
197
|
+
warned_missing_body = True
|
|
198
|
+
continue
|
|
199
|
+
|
|
200
|
+
headers = _sanitize_headers(raw_headers)
|
|
168
201
|
|
|
169
202
|
post_data = req.get("postData", {}) or {}
|
|
170
203
|
body = post_data.get("text") or None
|
|
@@ -188,6 +221,19 @@ def parse_har(
|
|
|
188
221
|
)
|
|
189
222
|
)
|
|
190
223
|
|
|
224
|
+
# Count how many times each path appears so occurrence numbers can be assigned.
|
|
225
|
+
totals: dict[str, int] = {}
|
|
226
|
+
for entry in result:
|
|
227
|
+
path = urlparse(entry.url).path
|
|
228
|
+
totals[path] = totals.get(path, 0) + 1
|
|
229
|
+
|
|
230
|
+
seen: dict[str, int] = {}
|
|
231
|
+
for entry in result:
|
|
232
|
+
path = urlparse(entry.url).path
|
|
233
|
+
seen[path] = seen.get(path, 0) + 1
|
|
234
|
+
entry.occurrence = seen[path]
|
|
235
|
+
entry.total_occurrences = totals[path]
|
|
236
|
+
|
|
191
237
|
return result
|
|
192
238
|
|
|
193
239
|
|
|
@@ -198,10 +244,10 @@ def _check_version(version: str) -> None:
|
|
|
198
244
|
"""Warn if the HAR version is outside the tested range."""
|
|
199
245
|
if version not in _SUPPORTED_HAR_VERSIONS:
|
|
200
246
|
print(
|
|
201
|
-
f"
|
|
247
|
+
f"\n\033[33mWarning: HAR version '{version}' is not tested with this tool "
|
|
202
248
|
f"(tested: {', '.join(sorted(_SUPPORTED_HAR_VERSIONS))}).\n"
|
|
203
249
|
"Parsing will continue but results may be incomplete or incorrect.\n"
|
|
204
|
-
"If the output looks wrong, check for a jac-loadtest update
|
|
250
|
+
"If the output looks wrong, check for a jac-loadtest update.\033[0m",
|
|
205
251
|
file=sys.stderr,
|
|
206
252
|
)
|
|
207
253
|
|
|
@@ -214,10 +260,10 @@ def _security_scan(entries: list[dict]) -> None:
|
|
|
214
260
|
value = hdr.get("value", "")
|
|
215
261
|
if name in ("authorization", "cookie") and value:
|
|
216
262
|
print(
|
|
217
|
-
"
|
|
263
|
+
"\n\033[33mWarning: HAR file contains Authorization/Cookie headers from the "
|
|
218
264
|
"recording session.\nThese headers are stripped before replay, but "
|
|
219
265
|
"the file itself contains sensitive data.\n"
|
|
220
|
-
"Do not commit this HAR file to version control
|
|
266
|
+
"Do not commit this HAR file to version control.\033[0m",
|
|
221
267
|
file=sys.stderr,
|
|
222
268
|
)
|
|
223
269
|
return
|