overload-cli 0.1.0__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.
- overload/__init__.py +3 -0
- overload/__main__.py +5 -0
- overload/cli.py +393 -0
- overload/collection/__init__.py +1 -0
- overload/collection/environment.py +23 -0
- overload/collection/models.py +88 -0
- overload/collection/parser.py +220 -0
- overload/collection/variables.py +84 -0
- overload/config_file.py +73 -0
- overload/engine/__init__.py +1 -0
- overload/engine/assertions.py +151 -0
- overload/engine/auth.py +87 -0
- overload/engine/events.py +50 -0
- overload/engine/http_client.py +274 -0
- overload/engine/load_patterns.py +730 -0
- overload/engine/models.py +254 -0
- overload/engine/rate_limiter.py +124 -0
- overload/engine/runner.py +86 -0
- overload/report/__init__.py +1 -0
- overload/report/exporters.py +77 -0
- overload/report/generator.py +71 -0
- overload/report/templates/report.html +369 -0
- overload/utils/__init__.py +1 -0
- overload/utils/naming.py +26 -0
- overload/web/__init__.py +1 -0
- overload/web/app.py +38 -0
- overload/web/routes/__init__.py +1 -0
- overload/web/routes/api.py +461 -0
- overload/web/routes/ws.py +77 -0
- overload/web/static/css/app.css +242 -0
- overload/web/static/js/app.js +241 -0
- overload/web/static/js/charts.js +385 -0
- overload/web/static/js/collection.js +344 -0
- overload/web/static/js/runner.js +625 -0
- overload/web/templates/index.html +23 -0
- overload_cli-0.1.0.dist-info/METADATA +267 -0
- overload_cli-0.1.0.dist-info/RECORD +40 -0
- overload_cli-0.1.0.dist-info/WHEEL +4 -0
- overload_cli-0.1.0.dist-info/entry_points.txt +2 -0
- overload_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import statistics
|
|
4
|
+
from collections import defaultdict
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from enum import Enum
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class TestType(str, Enum):
|
|
10
|
+
LOAD = "load"
|
|
11
|
+
STRESS = "stress"
|
|
12
|
+
SPIKE = "spike"
|
|
13
|
+
SOAK = "soak"
|
|
14
|
+
RAMP = "ramp"
|
|
15
|
+
BURST = "burst"
|
|
16
|
+
BREAKPOINT = "breakpoint"
|
|
17
|
+
CUSTOM = "custom"
|
|
18
|
+
RATE_LIMIT = "ratelimit"
|
|
19
|
+
SEQUENTIAL = "sequential"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class RequestDistribution(str, Enum):
|
|
23
|
+
ROUND_ROBIN = "round-robin"
|
|
24
|
+
RANDOM = "random"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class RequestResult:
|
|
29
|
+
request_name: str
|
|
30
|
+
method: str
|
|
31
|
+
url: str
|
|
32
|
+
status_code: int
|
|
33
|
+
latency_ms: float
|
|
34
|
+
timestamp: float
|
|
35
|
+
error: str | None = None
|
|
36
|
+
headers_sent: dict[str, str] = field(default_factory=dict)
|
|
37
|
+
headers_received: dict[str, str] = field(default_factory=dict)
|
|
38
|
+
body_size_bytes: int = 0
|
|
39
|
+
response_body: str | None = None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class RunProgress:
|
|
44
|
+
run_id: str
|
|
45
|
+
total_requests: int
|
|
46
|
+
completed_requests: int
|
|
47
|
+
current_rps: float
|
|
48
|
+
phase: str
|
|
49
|
+
elapsed_seconds: float
|
|
50
|
+
error_count: int = 0
|
|
51
|
+
status_codes: dict[int, int] = field(default_factory=dict)
|
|
52
|
+
avg_latency_ms: float = 0.0
|
|
53
|
+
recent_results: list[dict] = field(default_factory=list)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass
|
|
57
|
+
class PatternConfig:
|
|
58
|
+
concurrency: int = 20
|
|
59
|
+
timeout_seconds: float = 30.0
|
|
60
|
+
verify_ssl: bool = True
|
|
61
|
+
follow_redirects: bool = True
|
|
62
|
+
save_responses: bool = False
|
|
63
|
+
distribution: RequestDistribution = RequestDistribution.ROUND_ROBIN
|
|
64
|
+
think_time_ms: int = 0
|
|
65
|
+
|
|
66
|
+
# Load test
|
|
67
|
+
target_rps: int = 50
|
|
68
|
+
ramp_up_seconds: int = 30
|
|
69
|
+
hold_duration_seconds: int = 300
|
|
70
|
+
ramp_down_seconds: int = 10
|
|
71
|
+
|
|
72
|
+
# Stress test
|
|
73
|
+
start_rps: int = 10
|
|
74
|
+
step_rps: int = 20
|
|
75
|
+
step_duration_seconds: int = 30
|
|
76
|
+
failure_threshold_pct: float = 80.0
|
|
77
|
+
max_rps: int = 500
|
|
78
|
+
|
|
79
|
+
# Spike test
|
|
80
|
+
baseline_rps: int = 20
|
|
81
|
+
spike_rps: int = 200
|
|
82
|
+
baseline_duration_seconds: int = 60
|
|
83
|
+
spike_duration_seconds: int = 30
|
|
84
|
+
recovery_duration_seconds: int = 60
|
|
85
|
+
|
|
86
|
+
# Soak test
|
|
87
|
+
soak_rps: int = 30
|
|
88
|
+
soak_duration_seconds: int = 1800
|
|
89
|
+
|
|
90
|
+
# Ramp test
|
|
91
|
+
ramp_start_rps: int = 10
|
|
92
|
+
ramp_end_rps: int = 200
|
|
93
|
+
|
|
94
|
+
# Burst test
|
|
95
|
+
total_requests: int = 200
|
|
96
|
+
|
|
97
|
+
# Breakpoint test
|
|
98
|
+
precision_rps: int = 5
|
|
99
|
+
latency_threshold_ms: float = 2000.0
|
|
100
|
+
error_threshold_pct: float = 10.0
|
|
101
|
+
|
|
102
|
+
# Custom test
|
|
103
|
+
stages: list[dict] = field(default_factory=list)
|
|
104
|
+
|
|
105
|
+
# Rate limit test
|
|
106
|
+
rate_limit_cap: int = 60
|
|
107
|
+
rate_limit_requests: int = 120
|
|
108
|
+
|
|
109
|
+
# Sequential runner
|
|
110
|
+
iterations: int = 1
|
|
111
|
+
delay_ms: int = 0
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@dataclass
|
|
115
|
+
class Threshold:
|
|
116
|
+
metric: str
|
|
117
|
+
operator: str
|
|
118
|
+
value: float
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@dataclass
|
|
122
|
+
class AssertionResult:
|
|
123
|
+
metric: str
|
|
124
|
+
operator: str
|
|
125
|
+
expected: float
|
|
126
|
+
actual: float
|
|
127
|
+
passed: bool
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@dataclass
|
|
131
|
+
class Verdict:
|
|
132
|
+
passed: bool
|
|
133
|
+
results: list[AssertionResult]
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class Stats:
|
|
137
|
+
def __init__(self) -> None:
|
|
138
|
+
self.results: list[RequestResult] = []
|
|
139
|
+
|
|
140
|
+
def add(self, result: RequestResult) -> None:
|
|
141
|
+
self.results.append(result)
|
|
142
|
+
|
|
143
|
+
def add_all(self, results: list[RequestResult]) -> None:
|
|
144
|
+
self.results.extend(results)
|
|
145
|
+
|
|
146
|
+
@property
|
|
147
|
+
def total(self) -> int:
|
|
148
|
+
return len(self.results)
|
|
149
|
+
|
|
150
|
+
@property
|
|
151
|
+
def success_count(self) -> int:
|
|
152
|
+
return sum(1 for r in self.results if 200 <= r.status_code < 400)
|
|
153
|
+
|
|
154
|
+
@property
|
|
155
|
+
def error_count(self) -> int:
|
|
156
|
+
return sum(
|
|
157
|
+
1
|
|
158
|
+
for r in self.results
|
|
159
|
+
if r.status_code < 200 or r.status_code >= 400
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
@property
|
|
163
|
+
def rate_limited_count(self) -> int:
|
|
164
|
+
return sum(1 for r in self.results if r.status_code == 429)
|
|
165
|
+
|
|
166
|
+
def compute(self) -> dict | None:
|
|
167
|
+
if not self.results:
|
|
168
|
+
return None
|
|
169
|
+
|
|
170
|
+
status_counts: dict[int, int] = defaultdict(int)
|
|
171
|
+
for r in self.results:
|
|
172
|
+
status_counts[r.status_code] += 1
|
|
173
|
+
|
|
174
|
+
latencies = [r.latency_ms for r in self.results]
|
|
175
|
+
sorted_latencies = sorted(latencies)
|
|
176
|
+
|
|
177
|
+
ok = sum(v for k, v in status_counts.items() if 200 <= k < 400)
|
|
178
|
+
rl = status_counts.get(429, 0)
|
|
179
|
+
total = len(self.results)
|
|
180
|
+
|
|
181
|
+
t0 = self.results[0].timestamp
|
|
182
|
+
buckets: dict[int, dict[int, int]] = defaultdict(lambda: defaultdict(int))
|
|
183
|
+
for r in self.results:
|
|
184
|
+
buckets[int(r.timestamp - t0)][r.status_code] += 1
|
|
185
|
+
|
|
186
|
+
per_second = []
|
|
187
|
+
for sec in sorted(buckets):
|
|
188
|
+
b = buckets[sec]
|
|
189
|
+
sec_total = sum(b.values())
|
|
190
|
+
sec_ok = sum(v for k, v in b.items() if 200 <= k < 300)
|
|
191
|
+
sec_redirect = sum(v for k, v in b.items() if 300 <= k < 400)
|
|
192
|
+
sec_rl = b.get(429, 0)
|
|
193
|
+
sec_client = sum(v for k, v in b.items() if 400 <= k < 500 and k != 429)
|
|
194
|
+
sec_server = sum(v for k, v in b.items() if k >= 500)
|
|
195
|
+
sec_conn = sum(v for k, v in b.items() if k <= 0)
|
|
196
|
+
per_second.append({
|
|
197
|
+
"second": sec,
|
|
198
|
+
"total": sec_total,
|
|
199
|
+
"ok": sec_ok + sec_redirect,
|
|
200
|
+
"rate_limited": sec_rl,
|
|
201
|
+
"client_errors": sec_client,
|
|
202
|
+
"server_errors": sec_server,
|
|
203
|
+
"conn_errors": sec_conn,
|
|
204
|
+
"errors": sec_total - sec_ok - sec_redirect - sec_rl,
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
timeline = [
|
|
208
|
+
{
|
|
209
|
+
"timestamp": round(r.timestamp - t0, 3),
|
|
210
|
+
"status": r.status_code,
|
|
211
|
+
"latency_ms": round(r.latency_ms, 1),
|
|
212
|
+
"request_name": r.request_name,
|
|
213
|
+
}
|
|
214
|
+
for r in self.results
|
|
215
|
+
]
|
|
216
|
+
|
|
217
|
+
request_log = []
|
|
218
|
+
for r in self.results:
|
|
219
|
+
entry = {
|
|
220
|
+
"timestamp": round(r.timestamp - t0, 3),
|
|
221
|
+
"status": r.status_code,
|
|
222
|
+
"latency_ms": round(r.latency_ms, 1),
|
|
223
|
+
"method": r.method,
|
|
224
|
+
"url": r.url,
|
|
225
|
+
"request_name": r.request_name,
|
|
226
|
+
"error": r.error,
|
|
227
|
+
}
|
|
228
|
+
if r.response_body is not None:
|
|
229
|
+
entry["response_body"] = r.response_body
|
|
230
|
+
request_log.append(entry)
|
|
231
|
+
|
|
232
|
+
p95_idx = int(len(sorted_latencies) * 0.95)
|
|
233
|
+
p99_idx = int(len(sorted_latencies) * 0.99)
|
|
234
|
+
|
|
235
|
+
return {
|
|
236
|
+
"total": total,
|
|
237
|
+
"ok": ok,
|
|
238
|
+
"rate_limited": rl,
|
|
239
|
+
"errors": total - ok - rl,
|
|
240
|
+
"status_codes": dict(status_counts),
|
|
241
|
+
"latency": {
|
|
242
|
+
"min": round(min(latencies), 1),
|
|
243
|
+
"median": round(statistics.median(latencies), 1),
|
|
244
|
+
"mean": round(statistics.mean(latencies), 1),
|
|
245
|
+
"p95": round(sorted_latencies[min(p95_idx, total - 1)], 1),
|
|
246
|
+
"p99": round(sorted_latencies[min(p99_idx, total - 1)], 1),
|
|
247
|
+
"max": round(max(latencies), 1),
|
|
248
|
+
},
|
|
249
|
+
"per_second": per_second,
|
|
250
|
+
"timeline": timeline,
|
|
251
|
+
"request_log": request_log,
|
|
252
|
+
"duration_seconds": round(max(self.results[-1].timestamp - t0, 0.1), 1) if total > 1 else 0.1,
|
|
253
|
+
"avg_rps": round(total / max(self.results[-1].timestamp - t0, 0.1), 1),
|
|
254
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
import time
|
|
6
|
+
from collections.abc import Callable, Coroutine
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from overload.collection.models import ParsedRequest
|
|
10
|
+
from overload.collection.variables import VariableContext
|
|
11
|
+
from overload.engine.http_client import HttpClient
|
|
12
|
+
from overload.engine.load_patterns import _fire_one, _pick_request
|
|
13
|
+
from overload.engine.models import (
|
|
14
|
+
PatternConfig,
|
|
15
|
+
RequestDistribution,
|
|
16
|
+
RequestResult,
|
|
17
|
+
RunProgress,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
ProgressCallback = Callable[[RunProgress], Coroutine[Any, Any, None]]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
async def run_rate_limit_test(
|
|
26
|
+
client: HttpClient,
|
|
27
|
+
requests: list[ParsedRequest],
|
|
28
|
+
variables: VariableContext,
|
|
29
|
+
config: PatternConfig,
|
|
30
|
+
run_id: str,
|
|
31
|
+
cancel_event: asyncio.Event,
|
|
32
|
+
on_progress: ProgressCallback | None = None,
|
|
33
|
+
) -> tuple[list[RequestResult], list[dict]]:
|
|
34
|
+
cap = config.rate_limit_cap
|
|
35
|
+
total_requests = config.rate_limit_requests
|
|
36
|
+
concurrency = config.concurrency
|
|
37
|
+
sem = asyncio.Semaphore(concurrency)
|
|
38
|
+
all_results: list[RequestResult] = []
|
|
39
|
+
ramp_rows: list[dict] = []
|
|
40
|
+
start_time = time.monotonic()
|
|
41
|
+
request_idx = 0
|
|
42
|
+
|
|
43
|
+
logger.info("Rate Limit Test: cap=%d, requests=%d, concurrency=%d", cap, total_requests, concurrency)
|
|
44
|
+
|
|
45
|
+
# Phase 1: Burst test
|
|
46
|
+
if on_progress:
|
|
47
|
+
await on_progress(RunProgress(
|
|
48
|
+
run_id=run_id, total_requests=total_requests,
|
|
49
|
+
completed_requests=0, current_rps=0,
|
|
50
|
+
phase=f"Burst: {total_requests} requests", elapsed_seconds=0,
|
|
51
|
+
))
|
|
52
|
+
|
|
53
|
+
burst_tasks = [
|
|
54
|
+
asyncio.create_task(
|
|
55
|
+
_fire_one(
|
|
56
|
+
client,
|
|
57
|
+
_pick_request(requests, i, RequestDistribution.ROUND_ROBIN),
|
|
58
|
+
variables, sem,
|
|
59
|
+
)
|
|
60
|
+
)
|
|
61
|
+
for i in range(total_requests)
|
|
62
|
+
]
|
|
63
|
+
|
|
64
|
+
for coro in asyncio.as_completed(burst_tasks):
|
|
65
|
+
if cancel_event.is_set():
|
|
66
|
+
for t in burst_tasks:
|
|
67
|
+
t.cancel()
|
|
68
|
+
break
|
|
69
|
+
result = await coro
|
|
70
|
+
all_results.append(result)
|
|
71
|
+
request_idx += 1
|
|
72
|
+
|
|
73
|
+
# Phase 2: Ramp to find threshold
|
|
74
|
+
if not cancel_event.is_set():
|
|
75
|
+
logger.info("Rate limit ramp: 10 -> %d req/s", cap * 2)
|
|
76
|
+
|
|
77
|
+
for rps in range(10, cap * 2 + 1, 10):
|
|
78
|
+
if cancel_event.is_set():
|
|
79
|
+
break
|
|
80
|
+
|
|
81
|
+
interval = 1.0 / rps
|
|
82
|
+
batch_tasks = []
|
|
83
|
+
batch_start = time.monotonic()
|
|
84
|
+
|
|
85
|
+
for i in range(rps):
|
|
86
|
+
if cancel_event.is_set():
|
|
87
|
+
break
|
|
88
|
+
delay = batch_start + i * interval - time.monotonic()
|
|
89
|
+
if delay > 0:
|
|
90
|
+
await asyncio.sleep(delay)
|
|
91
|
+
req = _pick_request(requests, request_idx, RequestDistribution.ROUND_ROBIN)
|
|
92
|
+
request_idx += 1
|
|
93
|
+
batch_tasks.append(
|
|
94
|
+
asyncio.create_task(_fire_one(client, req, variables, sem))
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
batch_results = await asyncio.gather(*batch_tasks, return_exceptions=True)
|
|
98
|
+
valid = [r for r in batch_results if isinstance(r, RequestResult)]
|
|
99
|
+
all_results.extend(valid)
|
|
100
|
+
|
|
101
|
+
total = len(valid)
|
|
102
|
+
rl = sum(1 for r in valid if r.status_code == 429)
|
|
103
|
+
ok = sum(1 for r in valid if 200 <= r.status_code < 400)
|
|
104
|
+
pct = rl * 100 // total if total else 0
|
|
105
|
+
|
|
106
|
+
ramp_rows.append({"rps": rps, "ok": ok, "rate_limited": rl, "pct": pct})
|
|
107
|
+
|
|
108
|
+
logger.info("%d/s: ok=%d, 429=%d (%d%%)", rps, ok, rl, pct)
|
|
109
|
+
|
|
110
|
+
if on_progress:
|
|
111
|
+
elapsed = time.monotonic() - start_time
|
|
112
|
+
await on_progress(RunProgress(
|
|
113
|
+
run_id=run_id, total_requests=0,
|
|
114
|
+
completed_requests=len(all_results),
|
|
115
|
+
current_rps=rps,
|
|
116
|
+
phase=f"Ramp: {rps} req/s ({pct}% throttled)",
|
|
117
|
+
elapsed_seconds=round(elapsed, 1),
|
|
118
|
+
))
|
|
119
|
+
|
|
120
|
+
if pct >= 50:
|
|
121
|
+
logger.info("Rate limiting confirmed at %d req/s", rps)
|
|
122
|
+
break
|
|
123
|
+
|
|
124
|
+
return all_results, ramp_rows
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
import time
|
|
6
|
+
from collections.abc import Callable, Coroutine
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from overload.collection.models import ParsedRequest
|
|
10
|
+
from overload.collection.variables import VariableContext
|
|
11
|
+
from overload.engine.http_client import HttpClient
|
|
12
|
+
from overload.engine.models import PatternConfig, RequestResult, RunProgress
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
ProgressCallback = Callable[[RunProgress], Coroutine[Any, Any, None]]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
async def run_sequential(
|
|
20
|
+
client: HttpClient,
|
|
21
|
+
requests: list[ParsedRequest],
|
|
22
|
+
variables: VariableContext,
|
|
23
|
+
config: PatternConfig,
|
|
24
|
+
run_id: str,
|
|
25
|
+
cancel_event: asyncio.Event,
|
|
26
|
+
on_progress: ProgressCallback | None = None,
|
|
27
|
+
) -> list[RequestResult]:
|
|
28
|
+
iterations = config.iterations
|
|
29
|
+
delay_ms = config.delay_ms
|
|
30
|
+
total = len(requests) * iterations
|
|
31
|
+
all_results: list[RequestResult] = []
|
|
32
|
+
start_time = time.monotonic()
|
|
33
|
+
|
|
34
|
+
logger.info(
|
|
35
|
+
"Sequential: %d requests x %d iterations, delay=%dms",
|
|
36
|
+
len(requests), iterations, delay_ms,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
if on_progress:
|
|
40
|
+
try:
|
|
41
|
+
await on_progress(RunProgress(
|
|
42
|
+
run_id=run_id,
|
|
43
|
+
total_requests=total,
|
|
44
|
+
completed_requests=0,
|
|
45
|
+
current_rps=0,
|
|
46
|
+
phase="Preparing sequential run...",
|
|
47
|
+
elapsed_seconds=0,
|
|
48
|
+
))
|
|
49
|
+
except Exception:
|
|
50
|
+
logger.exception("Error in progress callback")
|
|
51
|
+
|
|
52
|
+
for iteration in range(1, iterations + 1):
|
|
53
|
+
if cancel_event.is_set():
|
|
54
|
+
break
|
|
55
|
+
|
|
56
|
+
for idx, request in enumerate(requests):
|
|
57
|
+
if cancel_event.is_set():
|
|
58
|
+
break
|
|
59
|
+
|
|
60
|
+
result = await client.execute(request, variables)
|
|
61
|
+
all_results.append(result)
|
|
62
|
+
|
|
63
|
+
logger.debug(
|
|
64
|
+
"Iter %d/%d, Req %d/%d: %s %s -> %d (%.1fms)",
|
|
65
|
+
iteration, iterations, idx + 1, len(requests),
|
|
66
|
+
result.method, result.url, result.status_code, result.latency_ms,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
if on_progress:
|
|
70
|
+
elapsed = time.monotonic() - start_time
|
|
71
|
+
try:
|
|
72
|
+
await on_progress(RunProgress(
|
|
73
|
+
run_id=run_id,
|
|
74
|
+
total_requests=total,
|
|
75
|
+
completed_requests=len(all_results),
|
|
76
|
+
current_rps=len(all_results) / max(elapsed, 0.001),
|
|
77
|
+
phase=f"Iteration {iteration}/{iterations}: {request.name}",
|
|
78
|
+
elapsed_seconds=round(elapsed, 1),
|
|
79
|
+
))
|
|
80
|
+
except Exception:
|
|
81
|
+
logger.exception("Error in progress callback")
|
|
82
|
+
|
|
83
|
+
if delay_ms > 0 and not (iteration == iterations and idx == len(requests) - 1):
|
|
84
|
+
await asyncio.sleep(delay_ms / 1000.0)
|
|
85
|
+
|
|
86
|
+
return all_results
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import csv
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
|
|
8
|
+
from overload.engine.models import Stats
|
|
9
|
+
from overload.utils.naming import stamped_filename
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def export_json(
|
|
15
|
+
stats: Stats,
|
|
16
|
+
test_type: str,
|
|
17
|
+
run_id: str,
|
|
18
|
+
output_dir: str = ".",
|
|
19
|
+
ramp_rows: list[dict] | None = None,
|
|
20
|
+
) -> str:
|
|
21
|
+
computed = stats.compute()
|
|
22
|
+
if not computed:
|
|
23
|
+
logger.warning("No results to export")
|
|
24
|
+
return ""
|
|
25
|
+
|
|
26
|
+
payload = {
|
|
27
|
+
"meta": {"run_id": run_id, "test_type": test_type},
|
|
28
|
+
"stats": computed,
|
|
29
|
+
"ramp_rows": ramp_rows or [],
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
filename = stamped_filename("overload_results", run_id, ".json")
|
|
33
|
+
filepath = os.path.join(output_dir, filename)
|
|
34
|
+
os.makedirs(output_dir, exist_ok=True)
|
|
35
|
+
|
|
36
|
+
with open(filepath, "w", encoding="utf-8") as f:
|
|
37
|
+
json.dump(payload, f, indent=2)
|
|
38
|
+
|
|
39
|
+
logger.info("JSON export: %s", os.path.abspath(filepath))
|
|
40
|
+
return filepath
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def export_csv(
|
|
44
|
+
stats: Stats,
|
|
45
|
+
run_id: str,
|
|
46
|
+
output_dir: str = ".",
|
|
47
|
+
) -> str:
|
|
48
|
+
if not stats.results:
|
|
49
|
+
logger.warning("No results to export")
|
|
50
|
+
return ""
|
|
51
|
+
|
|
52
|
+
filename = stamped_filename("overload_results", run_id, ".csv")
|
|
53
|
+
filepath = os.path.join(output_dir, filename)
|
|
54
|
+
os.makedirs(output_dir, exist_ok=True)
|
|
55
|
+
|
|
56
|
+
fieldnames = [
|
|
57
|
+
"timestamp", "request_name", "method", "url",
|
|
58
|
+
"status_code", "latency_ms", "error", "body_size_bytes",
|
|
59
|
+
]
|
|
60
|
+
|
|
61
|
+
with open(filepath, "w", encoding="utf-8", newline="") as f:
|
|
62
|
+
writer = csv.DictWriter(f, fieldnames=fieldnames)
|
|
63
|
+
writer.writeheader()
|
|
64
|
+
for r in stats.results:
|
|
65
|
+
writer.writerow({
|
|
66
|
+
"timestamp": r.timestamp,
|
|
67
|
+
"request_name": r.request_name,
|
|
68
|
+
"method": r.method,
|
|
69
|
+
"url": r.url,
|
|
70
|
+
"status_code": r.status_code,
|
|
71
|
+
"latency_ms": round(r.latency_ms, 1),
|
|
72
|
+
"error": r.error or "",
|
|
73
|
+
"body_size_bytes": r.body_size_bytes,
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
logger.info("CSV export: %s", os.path.abspath(filepath))
|
|
77
|
+
return filepath
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from jinja2 import Environment, FileSystemLoader
|
|
9
|
+
|
|
10
|
+
from overload.engine.models import Stats
|
|
11
|
+
from overload.utils.naming import generate_run_id, stamped_filename
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
TEMPLATES_DIR = Path(__file__).parent / "templates"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _create_jinja_env() -> Environment:
|
|
19
|
+
return Environment(
|
|
20
|
+
loader=FileSystemLoader(str(TEMPLATES_DIR)),
|
|
21
|
+
autoescape=True,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def generate_report(
|
|
26
|
+
stats: Stats,
|
|
27
|
+
test_type: str,
|
|
28
|
+
config: dict,
|
|
29
|
+
run_id: str | None = None,
|
|
30
|
+
ramp_rows: list[dict] | None = None,
|
|
31
|
+
output_dir: str = "reports",
|
|
32
|
+
verdict: dict | None = None,
|
|
33
|
+
) -> str:
|
|
34
|
+
run_id = run_id or generate_run_id()
|
|
35
|
+
computed = stats.compute()
|
|
36
|
+
if not computed:
|
|
37
|
+
logger.warning("No results to generate report from")
|
|
38
|
+
return ""
|
|
39
|
+
|
|
40
|
+
payload: dict = {
|
|
41
|
+
"meta": {
|
|
42
|
+
"run_id": run_id,
|
|
43
|
+
"test_type": test_type,
|
|
44
|
+
"config": config,
|
|
45
|
+
},
|
|
46
|
+
"stats": computed,
|
|
47
|
+
"ramp_rows": ramp_rows or [],
|
|
48
|
+
}
|
|
49
|
+
if verdict is not None:
|
|
50
|
+
payload["verdict"] = verdict
|
|
51
|
+
|
|
52
|
+
data_json = json.dumps(payload, separators=(",", ":"))
|
|
53
|
+
data_json = data_json.replace("</", "<\\/")
|
|
54
|
+
|
|
55
|
+
env = _create_jinja_env()
|
|
56
|
+
template = env.get_template("report.html")
|
|
57
|
+
html = template.render(
|
|
58
|
+
run_id=run_id,
|
|
59
|
+
test_type=test_type,
|
|
60
|
+
data_json=data_json,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
filename = stamped_filename("overload_report", run_id, ".html")
|
|
64
|
+
filepath = os.path.join(output_dir, filename)
|
|
65
|
+
os.makedirs(output_dir, exist_ok=True)
|
|
66
|
+
|
|
67
|
+
with open(filepath, "w", encoding="utf-8") as f:
|
|
68
|
+
f.write(html)
|
|
69
|
+
|
|
70
|
+
logger.info("Report generated: %s", os.path.abspath(filepath))
|
|
71
|
+
return filepath
|