jac-loadtest 0.2.1__tar.gz → 0.2.2__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.2}/PKG-INFO +1 -1
- {jac_loadtest-0.2.1 → jac_loadtest-0.2.2}/jac_loadtest/bridge/topology.py +3 -1
- {jac_loadtest-0.2.1 → jac_loadtest-0.2.2}/jac_loadtest/config.py +4 -3
- {jac_loadtest-0.2.1 → jac_loadtest-0.2.2}/jac_loadtest/core/engine.py +24 -8
- {jac_loadtest-0.2.1 → jac_loadtest-0.2.2}/jac_loadtest/core/har_parser.py +55 -9
- {jac_loadtest-0.2.1 → jac_loadtest-0.2.2}/jac_loadtest/core/metrics.py +8 -2
- {jac_loadtest-0.2.1 → jac_loadtest-0.2.2}/jac_loadtest.egg-info/PKG-INFO +1 -1
- {jac_loadtest-0.2.1 → jac_loadtest-0.2.2}/pyproject.toml +1 -1
- {jac_loadtest-0.2.1 → jac_loadtest-0.2.2}/README.md +0 -0
- {jac_loadtest-0.2.1 → jac_loadtest-0.2.2}/jac_loadtest/__init__.py +0 -0
- {jac_loadtest-0.2.1 → jac_loadtest-0.2.2}/jac_loadtest/bridge/__init__.py +0 -0
- {jac_loadtest-0.2.1 → jac_loadtest-0.2.2}/jac_loadtest/bridge/auth.py +0 -0
- {jac_loadtest-0.2.1 → jac_loadtest-0.2.2}/jac_loadtest/cli.py +0 -0
- {jac_loadtest-0.2.1 → jac_loadtest-0.2.2}/jac_loadtest/core/__init__.py +0 -0
- {jac_loadtest-0.2.1 → jac_loadtest-0.2.2}/jac_loadtest/output/__init__.py +0 -0
- {jac_loadtest-0.2.1 → jac_loadtest-0.2.2}/jac_loadtest/output/reporter.py +0 -0
- {jac_loadtest-0.2.1 → jac_loadtest-0.2.2}/jac_loadtest/plugin.py +0 -0
- {jac_loadtest-0.2.1 → jac_loadtest-0.2.2}/jac_loadtest.egg-info/SOURCES.txt +0 -0
- {jac_loadtest-0.2.1 → jac_loadtest-0.2.2}/jac_loadtest.egg-info/dependency_links.txt +0 -0
- {jac_loadtest-0.2.1 → jac_loadtest-0.2.2}/jac_loadtest.egg-info/entry_points.txt +0 -0
- {jac_loadtest-0.2.1 → jac_loadtest-0.2.2}/jac_loadtest.egg-info/requires.txt +0 -0
- {jac_loadtest-0.2.1 → jac_loadtest-0.2.2}/jac_loadtest.egg-info/top_level.txt +0 -0
- {jac_loadtest-0.2.1 → jac_loadtest-0.2.2}/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"
|
|
@@ -5,12 +5,13 @@ Phase 2 will add jac.toml reading via jac_scale.config_loader.
|
|
|
5
5
|
"""
|
|
6
6
|
from __future__ import annotations
|
|
7
7
|
from dataclasses import dataclass, field
|
|
8
|
+
from typing import Any
|
|
8
9
|
|
|
9
10
|
|
|
10
11
|
BUILT_IN_DEFAULTS: dict = {
|
|
11
12
|
"vus": 1,
|
|
12
13
|
"duration": "30s",
|
|
13
|
-
"iterations":
|
|
14
|
+
"iterations": 1,
|
|
14
15
|
"ramp_up": "0s",
|
|
15
16
|
"timeout": "30s",
|
|
16
17
|
"mode": "monolith",
|
|
@@ -36,7 +37,7 @@ class LoadTestConfig:
|
|
|
36
37
|
# Load shape
|
|
37
38
|
vus: int = 1
|
|
38
39
|
duration: str = "30s"
|
|
39
|
-
iterations: int
|
|
40
|
+
iterations: int = 1
|
|
40
41
|
ramp_up: str = "0s"
|
|
41
42
|
timeout: str = "30s"
|
|
42
43
|
|
|
@@ -107,7 +108,7 @@ def from_args(args: object) -> LoadTestConfig:
|
|
|
107
108
|
|
|
108
109
|
# For toml-sourced fields, use CLI value if provided (not None), else toml value,
|
|
109
110
|
# else built-in default.
|
|
110
|
-
def resolve(name: str) ->
|
|
111
|
+
def resolve(name: str) -> Any:
|
|
111
112
|
cli_val = getattr(args, name, None)
|
|
112
113
|
if cli_val is not None:
|
|
113
114
|
return cli_val
|
|
@@ -18,14 +18,16 @@ if TYPE_CHECKING:
|
|
|
18
18
|
from jac_loadtest.core.har_parser import HarEntry
|
|
19
19
|
from jac_loadtest.core.metrics import MetricsCollector
|
|
20
20
|
from jac_loadtest.config import LoadTestConfig
|
|
21
|
+
from jac_loadtest.bridge.topology import TopologyRouter
|
|
22
|
+
from jac_loadtest.bridge.auth import AuthProvider
|
|
21
23
|
|
|
22
24
|
|
|
23
25
|
async def run_all_vus(
|
|
24
26
|
entries: list[HarEntry],
|
|
25
27
|
config: LoadTestConfig,
|
|
26
28
|
metrics: MetricsCollector,
|
|
27
|
-
topology:
|
|
28
|
-
auth_provider:
|
|
29
|
+
topology: TopologyRouter | None = None,
|
|
30
|
+
auth_provider: AuthProvider | None = None,
|
|
29
31
|
) -> None:
|
|
30
32
|
"""Spawn N virtual user coroutines and run until duration/iterations/stop signal."""
|
|
31
33
|
stop_requested = asyncio.Event()
|
|
@@ -44,6 +46,9 @@ async def run_all_vus(
|
|
|
44
46
|
|
|
45
47
|
ramp_up_seconds = parse_duration(config.ramp_up)
|
|
46
48
|
|
|
49
|
+
# Limit concurrent logins to avoid exhausting OS sockets when VU count is large.
|
|
50
|
+
auth_semaphore = asyncio.Semaphore(50)
|
|
51
|
+
|
|
47
52
|
tasks = [
|
|
48
53
|
asyncio.create_task(
|
|
49
54
|
_run_vu(
|
|
@@ -56,6 +61,7 @@ async def run_all_vus(
|
|
|
56
61
|
loop=loop,
|
|
57
62
|
auth_provider=auth_provider,
|
|
58
63
|
topology=topology,
|
|
64
|
+
auth_semaphore=auth_semaphore,
|
|
59
65
|
)
|
|
60
66
|
)
|
|
61
67
|
for i in range(config.vus)
|
|
@@ -75,8 +81,9 @@ async def _run_vu(
|
|
|
75
81
|
metrics: MetricsCollector,
|
|
76
82
|
stop_requested: asyncio.Event,
|
|
77
83
|
loop: asyncio.AbstractEventLoop,
|
|
78
|
-
auth_provider:
|
|
79
|
-
topology:
|
|
84
|
+
auth_provider: AuthProvider | None = None,
|
|
85
|
+
topology: TopologyRouter | None = None,
|
|
86
|
+
auth_semaphore: asyncio.Semaphore | None = None,
|
|
80
87
|
) -> None:
|
|
81
88
|
"""Single virtual user: wait ramp delay, authenticate, then replay HAR entries."""
|
|
82
89
|
if delay > 0:
|
|
@@ -93,7 +100,10 @@ async def _run_vu(
|
|
|
93
100
|
# Authenticate once before entering the request loop.
|
|
94
101
|
token: str | None = None
|
|
95
102
|
if auth_provider is not None:
|
|
96
|
-
|
|
103
|
+
if not config.url:
|
|
104
|
+
raise ValueError("auth_provider requires --url to be set")
|
|
105
|
+
async with (auth_semaphore or asyncio.Semaphore()):
|
|
106
|
+
token = await auth_provider.authenticate(vu_id, session, config.url)
|
|
97
107
|
|
|
98
108
|
while not stop_requested.is_set():
|
|
99
109
|
if loop.time() - t_start >= duration_seconds:
|
|
@@ -123,8 +133,8 @@ async def _run_vu(
|
|
|
123
133
|
path = _up(entry.url).path
|
|
124
134
|
if path not in _warned_unrouted:
|
|
125
135
|
print(
|
|
126
|
-
f"
|
|
127
|
-
"Add a matching prefix to --services-map or set --url as fallback
|
|
136
|
+
f"\n\033[33mWarning: no route for '{path}' — skipping. "
|
|
137
|
+
"Add a matching prefix to --services-map or set --url as fallback.\033[0m",
|
|
128
138
|
file=sys.stderr,
|
|
129
139
|
)
|
|
130
140
|
_warned_unrouted.add(path)
|
|
@@ -145,7 +155,7 @@ async def _send_request(
|
|
|
145
155
|
config: LoadTestConfig,
|
|
146
156
|
loop: asyncio.AbstractEventLoop,
|
|
147
157
|
token: str | None = None,
|
|
148
|
-
topology:
|
|
158
|
+
topology: TopologyRouter | None = None,
|
|
149
159
|
) -> RequestResult | None:
|
|
150
160
|
"""Send one HTTP request and return a RequestResult, or None if no route exists."""
|
|
151
161
|
headers = dict(entry.headers)
|
|
@@ -185,6 +195,8 @@ async def _send_request(
|
|
|
185
195
|
timestamp=t0,
|
|
186
196
|
vu_id=vu_id,
|
|
187
197
|
error_type=None,
|
|
198
|
+
occurrence=entry.occurrence,
|
|
199
|
+
total_occurrences=entry.total_occurrences,
|
|
188
200
|
)
|
|
189
201
|
|
|
190
202
|
except asyncio.TimeoutError:
|
|
@@ -197,6 +209,8 @@ async def _send_request(
|
|
|
197
209
|
timestamp=t0,
|
|
198
210
|
vu_id=vu_id,
|
|
199
211
|
error_type="TIMEOUT",
|
|
212
|
+
occurrence=entry.occurrence,
|
|
213
|
+
total_occurrences=entry.total_occurrences,
|
|
200
214
|
)
|
|
201
215
|
|
|
202
216
|
except aiohttp.ClientConnectorError:
|
|
@@ -209,4 +223,6 @@ async def _send_request(
|
|
|
209
223
|
timestamp=t0,
|
|
210
224
|
vu_id=vu_id,
|
|
211
225
|
error_type="CONNECTION_REFUSED",
|
|
226
|
+
occurrence=entry.occurrence,
|
|
227
|
+
total_occurrences=entry.total_occurrences,
|
|
212
228
|
)
|
|
@@ -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
|
|
@@ -23,6 +23,8 @@ class RequestResult:
|
|
|
23
23
|
timestamp: float
|
|
24
24
|
vu_id: int
|
|
25
25
|
error_type: str | None # None | "TIMEOUT" | "CONNECTION_REFUSED" | "DNS_ERROR" | "SSL_ERROR"
|
|
26
|
+
occurrence: int = 1
|
|
27
|
+
total_occurrences: int = 1
|
|
26
28
|
|
|
27
29
|
|
|
28
30
|
@dataclass
|
|
@@ -113,11 +115,15 @@ class MetricsCollector:
|
|
|
113
115
|
error_breakdown: dict[str, int] = {}
|
|
114
116
|
for r in results:
|
|
115
117
|
if r.error_type is not None:
|
|
116
|
-
|
|
118
|
+
label = r.error_type
|
|
117
119
|
elif not (200 <= r.status < 300):
|
|
118
|
-
|
|
120
|
+
label = str(r.status)
|
|
119
121
|
else:
|
|
120
122
|
continue
|
|
123
|
+
if r.total_occurrences > 1:
|
|
124
|
+
key = f"{label} (call #{r.occurrence} of {r.total_occurrences})"
|
|
125
|
+
else:
|
|
126
|
+
key = label
|
|
121
127
|
error_breakdown[key] = error_breakdown.get(key, 0) + 1
|
|
122
128
|
|
|
123
129
|
service = results[0].service if results else "monolith"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|