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.
Files changed (26) hide show
  1. {jac_loadtest-0.2.2 → jac_loadtest-0.2.3}/PKG-INFO +1 -1
  2. {jac_loadtest-0.2.2 → jac_loadtest-0.2.3}/jac_loadtest/cli.py +62 -7
  3. {jac_loadtest-0.2.2 → jac_loadtest-0.2.3}/jac_loadtest/config.py +4 -0
  4. {jac_loadtest-0.2.2 → jac_loadtest-0.2.3}/jac_loadtest/core/engine.py +146 -31
  5. {jac_loadtest-0.2.2 → jac_loadtest-0.2.3}/jac_loadtest/core/metrics.py +53 -23
  6. jac_loadtest-0.2.3/jac_loadtest/core/process_runner.py +195 -0
  7. jac_loadtest-0.2.3/jac_loadtest/output/reporter.py +307 -0
  8. {jac_loadtest-0.2.2 → jac_loadtest-0.2.3}/jac_loadtest/plugin.py +4 -2
  9. jac_loadtest-0.2.3/jac_loadtest/templates/reporter_template.html +204 -0
  10. {jac_loadtest-0.2.2 → jac_loadtest-0.2.3}/jac_loadtest.egg-info/PKG-INFO +1 -1
  11. {jac_loadtest-0.2.2 → jac_loadtest-0.2.3}/jac_loadtest.egg-info/SOURCES.txt +3 -1
  12. {jac_loadtest-0.2.2 → jac_loadtest-0.2.3}/pyproject.toml +4 -1
  13. jac_loadtest-0.2.2/jac_loadtest/output/reporter.py +0 -115
  14. {jac_loadtest-0.2.2 → jac_loadtest-0.2.3}/README.md +0 -0
  15. {jac_loadtest-0.2.2 → jac_loadtest-0.2.3}/jac_loadtest/__init__.py +0 -0
  16. {jac_loadtest-0.2.2 → jac_loadtest-0.2.3}/jac_loadtest/bridge/__init__.py +0 -0
  17. {jac_loadtest-0.2.2 → jac_loadtest-0.2.3}/jac_loadtest/bridge/auth.py +0 -0
  18. {jac_loadtest-0.2.2 → jac_loadtest-0.2.3}/jac_loadtest/bridge/topology.py +0 -0
  19. {jac_loadtest-0.2.2 → jac_loadtest-0.2.3}/jac_loadtest/core/__init__.py +0 -0
  20. {jac_loadtest-0.2.2 → jac_loadtest-0.2.3}/jac_loadtest/core/har_parser.py +0 -0
  21. {jac_loadtest-0.2.2 → jac_loadtest-0.2.3}/jac_loadtest/output/__init__.py +0 -0
  22. {jac_loadtest-0.2.2 → jac_loadtest-0.2.3}/jac_loadtest.egg-info/dependency_links.txt +0 -0
  23. {jac_loadtest-0.2.2 → jac_loadtest-0.2.3}/jac_loadtest.egg-info/entry_points.txt +0 -0
  24. {jac_loadtest-0.2.2 → jac_loadtest-0.2.3}/jac_loadtest.egg-info/requires.txt +0 -0
  25. {jac_loadtest-0.2.2 → jac_loadtest-0.2.3}/jac_loadtest.egg-info/top_level.txt +0 -0
  26. {jac_loadtest-0.2.2 → jac_loadtest-0.2.3}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: jac-loadtest
3
- Version: 0.2.2
3
+ Version: 0.2.3
4
4
  Summary: HAR-based load testing CLI for jac-scale applications
5
5
  Requires-Python: >=3.12
6
6
  Description-Content-Type: text/markdown
@@ -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
- metrics = MetricsCollector(max_samples=config.max_samples)
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
- asyncio.run(
78
- run_all_vus(entries, config, metrics, topology=topology, auth_provider=auth_provider)
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(duration_s)
86
- render_console(stats, config, actual_duration_s=duration_s)
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
- # Limit concurrent logins to avoid exhausting OS sockets when VU count is large.
50
- auth_semaphore = asyncio.Semaphore(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
- auth_provider=auth_provider,
116
+ token=token_by_vu.get(vu_id_offset + i),
63
117
  topology=topology,
64
- auth_semaphore=auth_semaphore,
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
- auth_provider: AuthProvider | None = None,
138
+ token: str | None = None,
85
139
  topology: TopologyRouter | None = None,
86
- auth_semaphore: asyncio.Semaphore | None = None,
140
+ pre_resolved_hosts: dict[str, str] | None = None,
87
141
  ) -> None:
88
- """Single virtual user: wait ramp delay, authenticate, then replay HAR entries."""
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
- duration_seconds = parse_duration(config.duration)
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
- async with aiohttp.ClientSession(timeout=timeout) as session:
100
- # Authenticate once before entering the request loop.
101
- token: str | None = None
102
- if auth_provider is not None:
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)
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 the pre-loop step.
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
- # Endpoint identifier for metrics uses the original entry URL (path only, normalized).
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=t0,
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=t0,
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=t0,
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] every 5s (time-series charts)
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 compute_endpoint_stats(self, duration_seconds: float) -> list[EndpointStats]:
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 flush_snapshot(self, timestamp: float, duration_seconds: float) -> None:
153
- """Record a 5-second interval snapshot (for time-series charts)."""
154
- latencies = [r.latency_ms for r in self._samples]
155
- safe_duration = max(duration_seconds, 0.001)
156
- total = len(self._samples)
157
- error_count = sum(
158
- 1 for r in self._samples
159
- if r.error_type is not None or not (200 <= r.status < 300)
160
- )
161
- self._snapshots.append(
162
- StatsSnapshot(
163
- timestamp=timestamp,
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=self.total_count / safe_duration,
168
- error_rate_pct=(error_count / total * 100.0) if total else 0.0,
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