loaderup 0.1.2__tar.gz → 0.1.4__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.
- {loaderup-0.1.2 → loaderup-0.1.4}/PKG-INFO +71 -6
- loaderup-0.1.4/README.md +130 -0
- loaderup-0.1.4/agents/analyzer.py +202 -0
- {loaderup-0.1.2 → loaderup-0.1.4}/agents/generator.py +55 -0
- {loaderup-0.1.2 → loaderup-0.1.4}/agents/runner.py +5 -1
- {loaderup-0.1.2 → loaderup-0.1.4}/loader/demo_registry_target.py +3 -4
- {loaderup-0.1.2 → loaderup-0.1.4}/loader/main.py +1 -1
- {loaderup-0.1.2 → loaderup-0.1.4}/loader/models.py +9 -0
- {loaderup-0.1.2 → loaderup-0.1.4}/loader/pipeline.py +11 -1
- loaderup-0.1.4/loader/web/assets/app.js +1551 -0
- {loaderup-0.1.2 → loaderup-0.1.4}/loader/web/assets/styles.css +229 -0
- {loaderup-0.1.2 → loaderup-0.1.4}/loader/web/index.html +2 -2
- {loaderup-0.1.2 → loaderup-0.1.4}/loaderup/cli.py +1 -1
- {loaderup-0.1.2 → loaderup-0.1.4}/loaderup.egg-info/PKG-INFO +71 -6
- {loaderup-0.1.2 → loaderup-0.1.4}/pyproject.toml +1 -1
- loaderup-0.1.2/README.md +0 -65
- loaderup-0.1.2/agents/analyzer.py +0 -78
- loaderup-0.1.2/loader/web/assets/app.js +0 -773
- {loaderup-0.1.2 → loaderup-0.1.4}/loader/__init__.py +0 -0
- {loaderup-0.1.2 → loaderup-0.1.4}/loader/history.py +0 -0
- {loaderup-0.1.2 → loaderup-0.1.4}/loader/settings.py +0 -0
- {loaderup-0.1.2 → loaderup-0.1.4}/loader/store.py +0 -0
- {loaderup-0.1.2 → loaderup-0.1.4}/loaderup/__init__.py +0 -0
- {loaderup-0.1.2 → loaderup-0.1.4}/loaderup/autodiscovery.py +0 -0
- {loaderup-0.1.2 → loaderup-0.1.4}/loaderup/collector.py +0 -0
- {loaderup-0.1.2 → loaderup-0.1.4}/loaderup/decorators.py +0 -0
- {loaderup-0.1.2 → loaderup-0.1.4}/loaderup/importer.py +0 -0
- {loaderup-0.1.2 → loaderup-0.1.4}/loaderup/models.py +0 -0
- {loaderup-0.1.2 → loaderup-0.1.4}/loaderup/registry.py +0 -0
- {loaderup-0.1.2 → loaderup-0.1.4}/loaderup.egg-info/SOURCES.txt +0 -0
- {loaderup-0.1.2 → loaderup-0.1.4}/loaderup.egg-info/dependency_links.txt +0 -0
- {loaderup-0.1.2 → loaderup-0.1.4}/loaderup.egg-info/entry_points.txt +0 -0
- {loaderup-0.1.2 → loaderup-0.1.4}/loaderup.egg-info/requires.txt +0 -0
- {loaderup-0.1.2 → loaderup-0.1.4}/loaderup.egg-info/top_level.txt +0 -0
- {loaderup-0.1.2 → loaderup-0.1.4}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: loaderup
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.4
|
|
4
4
|
Summary: Lightweight load-testing API and CLI built around FastAPI, k6, and decorator-based target registration.
|
|
5
5
|
Author: Mahdi Haroun
|
|
6
6
|
License: MIT
|
|
@@ -33,7 +33,7 @@ Lightweight load-testing API and CLI built around FastAPI, k6, and decorator-bas
|
|
|
33
33
|
|
|
34
34
|
- Python `>=3.12`
|
|
35
35
|
- `uv` (recommended package manager)
|
|
36
|
-
- `k6`
|
|
36
|
+
- `k6`
|
|
37
37
|
|
|
38
38
|
## Install
|
|
39
39
|
|
|
@@ -47,7 +47,7 @@ uv pip install -e .
|
|
|
47
47
|
python -m loader.main
|
|
48
48
|
```
|
|
49
49
|
|
|
50
|
-
Server starts on `http://127.0.0.1:
|
|
50
|
+
Server starts on `http://127.0.0.1:5050` by default.
|
|
51
51
|
|
|
52
52
|
## Run via CLI
|
|
53
53
|
|
|
@@ -55,7 +55,7 @@ Server starts on `http://127.0.0.1:8000` by default.
|
|
|
55
55
|
python -m loaderup.cli up --app loader.main:app --targets loader.demo_registry_target --reload
|
|
56
56
|
```
|
|
57
57
|
|
|
58
|
-
This command opens the dashboard automatically at `http://127.0.0.1:
|
|
58
|
+
This command opens the dashboard automatically at `http://127.0.0.1:5050/`.
|
|
59
59
|
Use `--no-open-browser` if you want to disable auto-open.
|
|
60
60
|
|
|
61
61
|
You can also use fallback-friendly forms like:
|
|
@@ -64,10 +64,75 @@ You can also use fallback-friendly forms like:
|
|
|
64
64
|
python -m loaderup.cli up --app main:app --targets demo_registry_targets --reload
|
|
65
65
|
```
|
|
66
66
|
|
|
67
|
+
## Using the decorator with FastAPI
|
|
68
|
+
|
|
69
|
+
Create your FastAPI app as usual:
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
# main.py
|
|
73
|
+
from fastapi import FastAPI
|
|
74
|
+
|
|
75
|
+
app = FastAPI()
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@app.get("/")
|
|
79
|
+
def home():
|
|
80
|
+
return {"message": "ok"}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@app.post("/users")
|
|
84
|
+
def create_user(payload: dict):
|
|
85
|
+
return {"id": 1, **payload}
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Then create a module with the load targets you want LoaderUp to test. The decorated functions do not need to call your route handlers; importing this module registers the target metadata.
|
|
89
|
+
|
|
90
|
+
```python
|
|
91
|
+
# targets.py
|
|
92
|
+
from loaderup import load_target
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@load_target(
|
|
96
|
+
name="home page",
|
|
97
|
+
method="GET",
|
|
98
|
+
path="/",
|
|
99
|
+
expected_status=200,
|
|
100
|
+
tags=["public"],
|
|
101
|
+
)
|
|
102
|
+
def home_page():
|
|
103
|
+
pass
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@load_target(
|
|
107
|
+
name="create user",
|
|
108
|
+
method="POST",
|
|
109
|
+
path="/users",
|
|
110
|
+
payload_example={"name": "Ada"},
|
|
111
|
+
expected_status=200,
|
|
112
|
+
tags=["users"],
|
|
113
|
+
)
|
|
114
|
+
def create_user():
|
|
115
|
+
pass
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Start LoaderUp and tell it to import the target module:
|
|
119
|
+
|
|
120
|
+
```bash
|
|
121
|
+
python -m loaderup.cli up --app main:app --targets targets --reload
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Run all decorator-registered targets against your FastAPI app:
|
|
125
|
+
|
|
126
|
+
```bash
|
|
127
|
+
curl -X POST http://127.0.0.1:5050/run/registry \
|
|
128
|
+
-H 'Content-Type: application/json' \
|
|
129
|
+
-d '{"base_url":"http://127.0.0.1:5050","vus":10,"duration_seconds":30}'
|
|
130
|
+
```
|
|
131
|
+
|
|
67
132
|
## Health check
|
|
68
133
|
|
|
69
134
|
```bash
|
|
70
|
-
curl http://127.0.0.1:
|
|
135
|
+
curl http://127.0.0.1:5050/health
|
|
71
136
|
```
|
|
72
137
|
|
|
73
138
|
## Dashboard
|
|
@@ -75,7 +140,7 @@ curl http://127.0.0.1:8000/health
|
|
|
75
140
|
Open:
|
|
76
141
|
|
|
77
142
|
```bash
|
|
78
|
-
http://127.0.0.1:
|
|
143
|
+
http://127.0.0.1:5050/
|
|
79
144
|
```
|
|
80
145
|
|
|
81
146
|
Dashboard includes:
|
loaderup-0.1.4/README.md
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# LoaderUp
|
|
2
|
+
|
|
3
|
+
Lightweight load-testing API and CLI built around FastAPI, k6, and decorator-based target registration.
|
|
4
|
+
|
|
5
|
+
## Requirements
|
|
6
|
+
|
|
7
|
+
- Python `>=3.12`
|
|
8
|
+
- `uv` (recommended package manager)
|
|
9
|
+
- `k6`
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
uv pip install -e .
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Run API directly
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
python -m loader.main
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Server starts on `http://127.0.0.1:5050` by default.
|
|
24
|
+
|
|
25
|
+
## Run via CLI
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
python -m loaderup.cli up --app loader.main:app --targets loader.demo_registry_target --reload
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
This command opens the dashboard automatically at `http://127.0.0.1:5050/`.
|
|
32
|
+
Use `--no-open-browser` if you want to disable auto-open.
|
|
33
|
+
|
|
34
|
+
You can also use fallback-friendly forms like:
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
python -m loaderup.cli up --app main:app --targets demo_registry_targets --reload
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Using the decorator with FastAPI
|
|
41
|
+
|
|
42
|
+
Create your FastAPI app as usual:
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
# main.py
|
|
46
|
+
from fastapi import FastAPI
|
|
47
|
+
|
|
48
|
+
app = FastAPI()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@app.get("/")
|
|
52
|
+
def home():
|
|
53
|
+
return {"message": "ok"}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@app.post("/users")
|
|
57
|
+
def create_user(payload: dict):
|
|
58
|
+
return {"id": 1, **payload}
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Then create a module with the load targets you want LoaderUp to test. The decorated functions do not need to call your route handlers; importing this module registers the target metadata.
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
# targets.py
|
|
65
|
+
from loaderup import load_target
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@load_target(
|
|
69
|
+
name="home page",
|
|
70
|
+
method="GET",
|
|
71
|
+
path="/",
|
|
72
|
+
expected_status=200,
|
|
73
|
+
tags=["public"],
|
|
74
|
+
)
|
|
75
|
+
def home_page():
|
|
76
|
+
pass
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@load_target(
|
|
80
|
+
name="create user",
|
|
81
|
+
method="POST",
|
|
82
|
+
path="/users",
|
|
83
|
+
payload_example={"name": "Ada"},
|
|
84
|
+
expected_status=200,
|
|
85
|
+
tags=["users"],
|
|
86
|
+
)
|
|
87
|
+
def create_user():
|
|
88
|
+
pass
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Start LoaderUp and tell it to import the target module:
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
python -m loaderup.cli up --app main:app --targets targets --reload
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Run all decorator-registered targets against your FastAPI app:
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
curl -X POST http://127.0.0.1:5050/run/registry \
|
|
101
|
+
-H 'Content-Type: application/json' \
|
|
102
|
+
-d '{"base_url":"http://127.0.0.1:5050","vus":10,"duration_seconds":30}'
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
## Health check
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
curl http://127.0.0.1:5050/health
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
## Dashboard
|
|
112
|
+
|
|
113
|
+
Open:
|
|
114
|
+
|
|
115
|
+
```bash
|
|
116
|
+
http://127.0.0.1:5050/
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Dashboard includes:
|
|
120
|
+
|
|
121
|
+
- live status + progress stream
|
|
122
|
+
- metrics cards + quick chart bars
|
|
123
|
+
- saved run history (persisted in `artifacts/history/runs.jsonl`)
|
|
124
|
+
- React-based multi-tab control center (`Run Now`, `Live`, `Registered Targets`, `History`)
|
|
125
|
+
|
|
126
|
+
## Notes
|
|
127
|
+
|
|
128
|
+
- Registry targets are loaded when the module is imported.
|
|
129
|
+
- Use `/run/targets` to submit explicit targets.
|
|
130
|
+
- Use `/run/registry` to run all decorator-registered targets.
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
from typing import Any, Dict, Tuple
|
|
4
|
+
from loader.models import SummaryMetrics
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _metric_value(metrics: Dict[str, Any], metric_name: str, field: str):
|
|
8
|
+
metric = metrics.get(metric_name, {})
|
|
9
|
+
if not isinstance(metric, dict):
|
|
10
|
+
return None
|
|
11
|
+
return metric.get(field)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _checks_pass_rate(metrics: Dict[str, Any]) -> float | None:
|
|
15
|
+
checks = metrics.get("checks", {})
|
|
16
|
+
if not isinstance(checks, dict):
|
|
17
|
+
return None
|
|
18
|
+
|
|
19
|
+
# Some k6 outputs provide "value"
|
|
20
|
+
if "value" in checks:
|
|
21
|
+
return checks.get("value")
|
|
22
|
+
|
|
23
|
+
passes = checks.get("passes")
|
|
24
|
+
fails = checks.get("fails")
|
|
25
|
+
|
|
26
|
+
if isinstance(passes, (int, float)) and isinstance(fails, (int, float)):
|
|
27
|
+
total = passes + fails
|
|
28
|
+
if total > 0:
|
|
29
|
+
return passes / total
|
|
30
|
+
|
|
31
|
+
return None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _http_req_failed_rate(metrics: Dict[str, Any]) -> float | None:
|
|
35
|
+
failed = metrics.get("http_req_failed", {})
|
|
36
|
+
if not isinstance(failed, dict):
|
|
37
|
+
return None
|
|
38
|
+
|
|
39
|
+
# Best source in k6 summary output
|
|
40
|
+
value = failed.get("value")
|
|
41
|
+
if isinstance(value, (int, float)):
|
|
42
|
+
return float(value)
|
|
43
|
+
|
|
44
|
+
# Some formats may expose "rate"
|
|
45
|
+
rate = failed.get("rate")
|
|
46
|
+
if isinstance(rate, (int, float)):
|
|
47
|
+
return float(rate)
|
|
48
|
+
|
|
49
|
+
# Fallback: in k6 this metric is a Rate metric, and "passes"
|
|
50
|
+
# usually means failed requests, while "fails" means non-failed requests.
|
|
51
|
+
passes = failed.get("passes")
|
|
52
|
+
fails = failed.get("fails")
|
|
53
|
+
|
|
54
|
+
if isinstance(passes, (int, float)) and isinstance(fails, (int, float)):
|
|
55
|
+
total = passes + fails
|
|
56
|
+
if total > 0:
|
|
57
|
+
return float(passes) / float(total)
|
|
58
|
+
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def parse_k6_summary(summary_path: str) -> Tuple[SummaryMetrics, Dict[str, Any]]:
|
|
63
|
+
with open(summary_path, "r", encoding="utf-8") as f:
|
|
64
|
+
data = json.load(f)
|
|
65
|
+
|
|
66
|
+
metrics = data.get("metrics", {})
|
|
67
|
+
|
|
68
|
+
parsed = SummaryMetrics(
|
|
69
|
+
total_requests=_metric_value(metrics, "http_reqs", "count"),
|
|
70
|
+
avg_http_req_duration_ms=_metric_value(metrics, "http_req_duration", "avg"),
|
|
71
|
+
med_http_req_duration_ms=_metric_value(metrics, "http_req_duration", "med"),
|
|
72
|
+
p90_http_req_duration_ms=_metric_value(metrics, "http_req_duration", "p(90)"),
|
|
73
|
+
p95_http_req_duration_ms=_metric_value(metrics, "http_req_duration", "p(95)"),
|
|
74
|
+
max_http_req_duration_ms=_metric_value(metrics, "http_req_duration", "max"),
|
|
75
|
+
http_req_failed_rate=_http_req_failed_rate(metrics),
|
|
76
|
+
checks_pass_rate=_checks_pass_rate(metrics),
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
return parsed, data
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _decode_diag_json(raw_json: str) -> dict[str, Any] | None:
|
|
83
|
+
candidates = [
|
|
84
|
+
raw_json,
|
|
85
|
+
raw_json.replace('\\"', '"'),
|
|
86
|
+
raw_json.encode("utf-8", errors="ignore").decode("unicode_escape", errors="ignore"),
|
|
87
|
+
]
|
|
88
|
+
|
|
89
|
+
for candidate in candidates:
|
|
90
|
+
try:
|
|
91
|
+
data = json.loads(candidate)
|
|
92
|
+
except json.JSONDecodeError:
|
|
93
|
+
continue
|
|
94
|
+
if isinstance(data, dict):
|
|
95
|
+
return data
|
|
96
|
+
return None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def parse_k6_diagnostics(stdout_text: str, stderr_text: str = "") -> list[dict[str, Any]]:
|
|
100
|
+
diagnostics: list[dict[str, Any]] = []
|
|
101
|
+
merged = "\n".join([stdout_text or "", stderr_text or ""])
|
|
102
|
+
if not merged.strip():
|
|
103
|
+
return diagnostics
|
|
104
|
+
|
|
105
|
+
marker = "LOADERUP_DIAG "
|
|
106
|
+
|
|
107
|
+
for line in merged.splitlines():
|
|
108
|
+
if marker not in line:
|
|
109
|
+
continue
|
|
110
|
+
|
|
111
|
+
payload_part = line.split(marker, 1)[1]
|
|
112
|
+
start = payload_part.find("{")
|
|
113
|
+
end = payload_part.rfind("}")
|
|
114
|
+
if start == -1 or end == -1 or end <= start:
|
|
115
|
+
continue
|
|
116
|
+
|
|
117
|
+
raw_json = payload_part[start : end + 1]
|
|
118
|
+
data = _decode_diag_json(raw_json)
|
|
119
|
+
if not data:
|
|
120
|
+
continue
|
|
121
|
+
|
|
122
|
+
diagnostics.append(data)
|
|
123
|
+
|
|
124
|
+
return diagnostics
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _to_dt(value: str) -> datetime | None:
|
|
128
|
+
text = str(value or "").strip()
|
|
129
|
+
if not text:
|
|
130
|
+
return None
|
|
131
|
+
if text.endswith("Z"):
|
|
132
|
+
text = text[:-1] + "+00:00"
|
|
133
|
+
try:
|
|
134
|
+
return datetime.fromisoformat(text)
|
|
135
|
+
except ValueError:
|
|
136
|
+
return None
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _p95(values: list[float]) -> float | None:
|
|
140
|
+
if not values:
|
|
141
|
+
return None
|
|
142
|
+
ordered = sorted(values)
|
|
143
|
+
idx = max(0, min(len(ordered) - 1, int(0.95 * (len(ordered) - 1))))
|
|
144
|
+
return float(ordered[idx])
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def parse_k6_latency_timeseries(stream_path: str) -> list[dict[str, Any]]:
|
|
148
|
+
buckets: dict[int, list[float]] = {}
|
|
149
|
+
start_time: datetime | None = None
|
|
150
|
+
|
|
151
|
+
with open(stream_path, "r", encoding="utf-8") as handle:
|
|
152
|
+
for line in handle:
|
|
153
|
+
line = line.strip()
|
|
154
|
+
if not line:
|
|
155
|
+
continue
|
|
156
|
+
|
|
157
|
+
try:
|
|
158
|
+
entry = json.loads(line)
|
|
159
|
+
except json.JSONDecodeError:
|
|
160
|
+
continue
|
|
161
|
+
|
|
162
|
+
if entry.get("type") != "Point" or entry.get("metric") != "http_req_duration":
|
|
163
|
+
continue
|
|
164
|
+
|
|
165
|
+
data = entry.get("data")
|
|
166
|
+
if not isinstance(data, dict):
|
|
167
|
+
continue
|
|
168
|
+
|
|
169
|
+
value = data.get("value")
|
|
170
|
+
if not isinstance(value, (int, float)):
|
|
171
|
+
continue
|
|
172
|
+
|
|
173
|
+
t = _to_dt(data.get("time", ""))
|
|
174
|
+
if not t:
|
|
175
|
+
continue
|
|
176
|
+
|
|
177
|
+
if start_time is None:
|
|
178
|
+
start_time = t
|
|
179
|
+
|
|
180
|
+
second = int((t - start_time).total_seconds()) + 1
|
|
181
|
+
if second < 1:
|
|
182
|
+
second = 1
|
|
183
|
+
|
|
184
|
+
buckets.setdefault(second, []).append(float(value))
|
|
185
|
+
|
|
186
|
+
if not buckets:
|
|
187
|
+
return []
|
|
188
|
+
|
|
189
|
+
points: list[dict[str, Any]] = []
|
|
190
|
+
for second in sorted(buckets):
|
|
191
|
+
values = buckets[second]
|
|
192
|
+
avg = sum(values) / len(values)
|
|
193
|
+
points.append(
|
|
194
|
+
{
|
|
195
|
+
"second": second,
|
|
196
|
+
"avg_ms": float(avg),
|
|
197
|
+
"p95_ms": _p95(values),
|
|
198
|
+
"count": len(values),
|
|
199
|
+
}
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
return points
|
|
@@ -33,6 +33,7 @@ def build_k6_script(
|
|
|
33
33
|
|
|
34
34
|
script = f"""import http from 'k6/http';
|
|
35
35
|
import {{ check, sleep }} from 'k6';
|
|
36
|
+
import {{ Counter }} from 'k6/metrics';
|
|
36
37
|
|
|
37
38
|
export const options = {{
|
|
38
39
|
vus: {vus},
|
|
@@ -41,6 +42,24 @@ export const options = {{
|
|
|
41
42
|
|
|
42
43
|
const BASE_URL = {_js_string(base_url.rstrip("/"))};
|
|
43
44
|
const TARGETS = {targets_json};
|
|
45
|
+
const STATUS_CODE_COUNT = new Counter('status_code_count');
|
|
46
|
+
const STATUS_429_SERVER_COUNT = new Counter('status_429_server_count');
|
|
47
|
+
const MAX_DIAG_PER_RUN = 6;
|
|
48
|
+
let diagCount = 0;
|
|
49
|
+
|
|
50
|
+
function normalizeHeaders(headers) {{
|
|
51
|
+
const normalized = {{}};
|
|
52
|
+
for (const [key, value] of Object.entries(headers || {{}})) {{
|
|
53
|
+
if (Array.isArray(value)) {{
|
|
54
|
+
normalized[key] = value.join(', ');
|
|
55
|
+
}} else if (value === null || value === undefined) {{
|
|
56
|
+
normalized[key] = '';
|
|
57
|
+
}} else {{
|
|
58
|
+
normalized[key] = String(value);
|
|
59
|
+
}}
|
|
60
|
+
}}
|
|
61
|
+
return normalized;
|
|
62
|
+
}}
|
|
44
63
|
|
|
45
64
|
function weightedPick(targets) {{
|
|
46
65
|
const expanded = [];
|
|
@@ -93,8 +112,44 @@ export default function () {{
|
|
|
93
112
|
check(res, {{
|
|
94
113
|
[`status is expected`]: (r) => r.status === target.expected_status,
|
|
95
114
|
[`status is < 500`]: (r) => r.status < 500,
|
|
115
|
+
[`status code ${{res.status}}`]: () => true,
|
|
96
116
|
}});
|
|
97
117
|
|
|
118
|
+
STATUS_CODE_COUNT.add(1, {{
|
|
119
|
+
status_code: String(res.status),
|
|
120
|
+
target_name: target.name,
|
|
121
|
+
method,
|
|
122
|
+
path: target.path,
|
|
123
|
+
}});
|
|
124
|
+
|
|
125
|
+
if (res.status === 429) {{
|
|
126
|
+
const headers = normalizeHeaders(res.headers);
|
|
127
|
+
const serverSignature = headers.Server || headers.server || 'unknown';
|
|
128
|
+
STATUS_429_SERVER_COUNT.add(1, {{
|
|
129
|
+
server_signature: String(serverSignature || 'unknown'),
|
|
130
|
+
target_name: target.name,
|
|
131
|
+
method,
|
|
132
|
+
path: target.path,
|
|
133
|
+
}});
|
|
134
|
+
check(res, {{
|
|
135
|
+
[`429 server ${{String(serverSignature || 'unknown')}}`]: () => true,
|
|
136
|
+
}});
|
|
137
|
+
}}
|
|
138
|
+
|
|
139
|
+
if (res.status === 429 && diagCount < MAX_DIAG_PER_RUN) {{
|
|
140
|
+
diagCount += 1;
|
|
141
|
+
const diagnostic = {{
|
|
142
|
+
status: res.status,
|
|
143
|
+
target_name: target.name,
|
|
144
|
+
method,
|
|
145
|
+
path: target.path,
|
|
146
|
+
url,
|
|
147
|
+
headers: normalizeHeaders(res.headers),
|
|
148
|
+
body_preview: String(res.body || '').slice(0, 2000),
|
|
149
|
+
}};
|
|
150
|
+
console.log(`LOADERUP_DIAG ${{JSON.stringify(diagnostic)}}`);
|
|
151
|
+
}}
|
|
152
|
+
|
|
98
153
|
sleep(1);
|
|
99
154
|
}}
|
|
100
155
|
"""
|
|
@@ -7,12 +7,15 @@ async def run_k6(script_path: str, job_id: str) -> dict:
|
|
|
7
7
|
out_dir.mkdir(parents=True, exist_ok=True)
|
|
8
8
|
|
|
9
9
|
summary_path = out_dir / "summary.json"
|
|
10
|
+
metrics_stream_path = out_dir / "metrics.jsonl"
|
|
10
11
|
|
|
11
12
|
process = await asyncio.create_subprocess_exec(
|
|
12
13
|
"k6",
|
|
13
14
|
"run",
|
|
14
15
|
"--summary-export",
|
|
15
16
|
str(summary_path),
|
|
17
|
+
"--out",
|
|
18
|
+
f"json={metrics_stream_path}",
|
|
16
19
|
script_path,
|
|
17
20
|
stdout=asyncio.subprocess.PIPE,
|
|
18
21
|
stderr=asyncio.subprocess.PIPE,
|
|
@@ -35,6 +38,7 @@ async def run_k6(script_path: str, job_id: str) -> dict:
|
|
|
35
38
|
|
|
36
39
|
return {
|
|
37
40
|
"summary_path": str(summary_path),
|
|
41
|
+
"metrics_stream_path": str(metrics_stream_path),
|
|
38
42
|
"stdout": stdout_text,
|
|
39
43
|
"stderr": stderr_text,
|
|
40
|
-
}
|
|
44
|
+
}
|
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
# demo_registry_targets.py
|
|
2
2
|
|
|
3
3
|
from loaderup import load_target
|
|
4
|
-
|
|
5
|
-
"""
|
|
4
|
+
'''
|
|
6
5
|
@load_target(
|
|
7
6
|
name="home",
|
|
8
7
|
method="GET",
|
|
@@ -12,7 +11,7 @@ from loaderup import load_target
|
|
|
12
11
|
)
|
|
13
12
|
def home():
|
|
14
13
|
pass
|
|
15
|
-
|
|
14
|
+
'''
|
|
16
15
|
@load_target(
|
|
17
16
|
name="home",
|
|
18
17
|
method="POST",
|
|
@@ -22,4 +21,4 @@ def home():
|
|
|
22
21
|
)
|
|
23
22
|
def gg():
|
|
24
23
|
pass
|
|
25
|
-
|
|
24
|
+
|
|
@@ -74,6 +74,13 @@ class TargetMetric(BaseModel):
|
|
|
74
74
|
note: Optional[str] = None
|
|
75
75
|
|
|
76
76
|
|
|
77
|
+
class LatencySeriesPoint(BaseModel):
|
|
78
|
+
second: int
|
|
79
|
+
avg_ms: Optional[float] = None
|
|
80
|
+
p95_ms: Optional[float] = None
|
|
81
|
+
count: int = 0
|
|
82
|
+
|
|
83
|
+
|
|
77
84
|
class JobResult(BaseModel):
|
|
78
85
|
config: Optional[RunConfig] = None
|
|
79
86
|
k6_script_path: Optional[str] = None
|
|
@@ -83,6 +90,8 @@ class JobResult(BaseModel):
|
|
|
83
90
|
metrics: Optional[SummaryMetrics] = None
|
|
84
91
|
target_metrics: List[TargetMetric] = Field(default_factory=list)
|
|
85
92
|
raw_summary: Optional[Dict[str, Any]] = None
|
|
93
|
+
status_diagnostics: List[Dict[str, Any]] = Field(default_factory=list)
|
|
94
|
+
latency_timeseries: List[LatencySeriesPoint] = Field(default_factory=list)
|
|
86
95
|
|
|
87
96
|
|
|
88
97
|
class Job(BaseModel):
|
|
@@ -3,7 +3,11 @@ from loader.history import persist_job
|
|
|
3
3
|
from loader.store import get_job, update_job, push_event, close_queue
|
|
4
4
|
from agents.generator import build_k6_script, save_k6_script
|
|
5
5
|
from agents.runner import run_k6
|
|
6
|
-
from agents.analyzer import
|
|
6
|
+
from agents.analyzer import (
|
|
7
|
+
parse_k6_summary,
|
|
8
|
+
parse_k6_diagnostics,
|
|
9
|
+
parse_k6_latency_timeseries,
|
|
10
|
+
)
|
|
7
11
|
|
|
8
12
|
|
|
9
13
|
async def set_status(job, status: JobStatus, message: str):
|
|
@@ -66,6 +70,9 @@ async def run_targets_pipeline(job_id: str, config: RunConfig):
|
|
|
66
70
|
job.result.k6_stdout = run_output["stdout"]
|
|
67
71
|
if hasattr(job.result, "k6_stderr"):
|
|
68
72
|
job.result.k6_stderr = run_output["stderr"]
|
|
73
|
+
job.result.status_diagnostics = parse_k6_diagnostics(
|
|
74
|
+
run_output["stdout"], run_output.get("stderr", "")
|
|
75
|
+
)
|
|
69
76
|
|
|
70
77
|
update_job(job)
|
|
71
78
|
|
|
@@ -82,6 +89,9 @@ async def run_targets_pipeline(job_id: str, config: RunConfig):
|
|
|
82
89
|
metrics, raw_summary = parse_k6_summary(run_output["summary_path"])
|
|
83
90
|
job.result.metrics = metrics
|
|
84
91
|
job.result.raw_summary = raw_summary
|
|
92
|
+
stream_path = run_output.get("metrics_stream_path")
|
|
93
|
+
if stream_path:
|
|
94
|
+
job.result.latency_timeseries = parse_k6_latency_timeseries(stream_path)
|
|
85
95
|
update_job(job)
|
|
86
96
|
|
|
87
97
|
await push_event(
|