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.
Files changed (35) hide show
  1. {loaderup-0.1.2 → loaderup-0.1.4}/PKG-INFO +71 -6
  2. loaderup-0.1.4/README.md +130 -0
  3. loaderup-0.1.4/agents/analyzer.py +202 -0
  4. {loaderup-0.1.2 → loaderup-0.1.4}/agents/generator.py +55 -0
  5. {loaderup-0.1.2 → loaderup-0.1.4}/agents/runner.py +5 -1
  6. {loaderup-0.1.2 → loaderup-0.1.4}/loader/demo_registry_target.py +3 -4
  7. {loaderup-0.1.2 → loaderup-0.1.4}/loader/main.py +1 -1
  8. {loaderup-0.1.2 → loaderup-0.1.4}/loader/models.py +9 -0
  9. {loaderup-0.1.2 → loaderup-0.1.4}/loader/pipeline.py +11 -1
  10. loaderup-0.1.4/loader/web/assets/app.js +1551 -0
  11. {loaderup-0.1.2 → loaderup-0.1.4}/loader/web/assets/styles.css +229 -0
  12. {loaderup-0.1.2 → loaderup-0.1.4}/loader/web/index.html +2 -2
  13. {loaderup-0.1.2 → loaderup-0.1.4}/loaderup/cli.py +1 -1
  14. {loaderup-0.1.2 → loaderup-0.1.4}/loaderup.egg-info/PKG-INFO +71 -6
  15. {loaderup-0.1.2 → loaderup-0.1.4}/pyproject.toml +1 -1
  16. loaderup-0.1.2/README.md +0 -65
  17. loaderup-0.1.2/agents/analyzer.py +0 -78
  18. loaderup-0.1.2/loader/web/assets/app.js +0 -773
  19. {loaderup-0.1.2 → loaderup-0.1.4}/loader/__init__.py +0 -0
  20. {loaderup-0.1.2 → loaderup-0.1.4}/loader/history.py +0 -0
  21. {loaderup-0.1.2 → loaderup-0.1.4}/loader/settings.py +0 -0
  22. {loaderup-0.1.2 → loaderup-0.1.4}/loader/store.py +0 -0
  23. {loaderup-0.1.2 → loaderup-0.1.4}/loaderup/__init__.py +0 -0
  24. {loaderup-0.1.2 → loaderup-0.1.4}/loaderup/autodiscovery.py +0 -0
  25. {loaderup-0.1.2 → loaderup-0.1.4}/loaderup/collector.py +0 -0
  26. {loaderup-0.1.2 → loaderup-0.1.4}/loaderup/decorators.py +0 -0
  27. {loaderup-0.1.2 → loaderup-0.1.4}/loaderup/importer.py +0 -0
  28. {loaderup-0.1.2 → loaderup-0.1.4}/loaderup/models.py +0 -0
  29. {loaderup-0.1.2 → loaderup-0.1.4}/loaderup/registry.py +0 -0
  30. {loaderup-0.1.2 → loaderup-0.1.4}/loaderup.egg-info/SOURCES.txt +0 -0
  31. {loaderup-0.1.2 → loaderup-0.1.4}/loaderup.egg-info/dependency_links.txt +0 -0
  32. {loaderup-0.1.2 → loaderup-0.1.4}/loaderup.egg-info/entry_points.txt +0 -0
  33. {loaderup-0.1.2 → loaderup-0.1.4}/loaderup.egg-info/requires.txt +0 -0
  34. {loaderup-0.1.2 → loaderup-0.1.4}/loaderup.egg-info/top_level.txt +0 -0
  35. {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.2
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` (optional but recommended for real load runs)
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:8000` by default.
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:8000/`.
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:8000/health
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:8000/
143
+ http://127.0.0.1:5050/
79
144
  ```
80
145
 
81
146
  Dashboard includes:
@@ -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
+
@@ -147,4 +147,4 @@ async def get_run_history(limit: int = 20):
147
147
  if __name__ == "__main__":
148
148
  import uvicorn
149
149
 
150
- uvicorn.run(app, host="0.0.0.0", port=8000)
150
+ uvicorn.run(app, host="0.0.0.0", port=5050)
@@ -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 parse_k6_summary
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(