Skip to content

Commit 50bee10

Browse files
committed
setup k6 report in apps/web
1 parent ed392a4 commit 50bee10

File tree

9 files changed

+826
-22
lines changed

9 files changed

+826
-22
lines changed

.github/workflows/k6.yaml

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
on:
22
workflow_dispatch:
3+
inputs:
4+
test_duration:
5+
description: "Test duration (e.g., 1h, 2h)"
6+
default: "1h"
7+
vus:
8+
description: "Number of virtual users"
9+
default: "30"
310

411
jobs:
512
load-test:
@@ -19,12 +26,22 @@ jobs:
1926

2027
- uses: grafana/setup-k6-action@v1
2128

22-
- uses: grafana/run-k6-action@v1
23-
with:
24-
path: apps/k6/scripts/websocket/listen.js
29+
- run: k6 run apps/k6/scripts/stt-live.js
2530
env:
2631
API_URL: wss://hyprnote-api-loadtest.fly.dev
2732
AUTH_TOKEN: ${{ secrets.K6_AUTH_TOKEN }}
33+
TEST_DURATION: ${{ inputs.test_duration }}
34+
VUS: ${{ inputs.vus }}
35+
FLY_ORG: ${{ secrets.FLY_ORG }}
36+
FLY_APP: hyprnote-api-loadtest
37+
FLY_TOKEN: ${{ secrets.FLY_API_TOKEN }}
38+
39+
- uses: actions/upload-artifact@v4
40+
if: always()
41+
with:
42+
name: k6-report-${{ github.run_id }}
43+
path: stt-live-*.json
44+
retention-days: 90
2845

2946
- run: flyctl scale count 0 --app hyprnote-api-loadtest --yes
3047
if: always()

apps/k6/scripts/stt-live.js

Lines changed: 203 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,36 @@
11
import { check, sleep } from "k6";
22
import http from "k6/http";
3-
import { Counter, Trend } from "k6/metrics";
3+
import { Counter, Rate, Trend } from "k6/metrics";
44
import ws from "k6/ws";
55

6+
const FLY_ORG = __ENV.FLY_ORG || "";
7+
const FLY_APP = __ENV.FLY_APP || "";
8+
const FLY_TOKEN = __ENV.FLY_TOKEN || "";
9+
10+
const GITHUB_RUN_ID = __ENV.GITHUB_RUN_ID || "";
11+
const GITHUB_REPOSITORY = __ENV.GITHUB_REPOSITORY || "";
12+
613
const wsConnections = new Counter("ws_connections");
714
const wsTranscripts = new Counter("ws_transcripts_received");
15+
const wsErrors = new Counter("ws_errors");
16+
const wsReconnects = new Counter("ws_reconnects");
817
const wsConnectionDuration = new Trend("ws_connection_duration");
918
const wsFirstTranscriptLatency = new Trend("ws_first_transcript_latency");
19+
const wsConnectionSuccess = new Rate("ws_connection_success");
20+
21+
const TEST_DURATION = __ENV.TEST_DURATION || "1h";
22+
const VUS = parseInt(__ENV.VUS) || 30;
1023

1124
export const options = {
12-
vus: 10,
13-
duration: "30s",
25+
stages: [
26+
{ duration: "1m", target: VUS },
27+
{ duration: TEST_DURATION, target: VUS },
28+
{ duration: "30s", target: 0 },
29+
],
1430
thresholds: {
1531
ws_connections: ["count > 0"],
32+
ws_connection_success: ["rate > 0.95"],
33+
ws_errors: ["count < 50"],
1634
checks: ["rate > 0.9"],
1735
},
1836
};
@@ -22,22 +40,23 @@ const AUTH_TOKEN = __ENV.AUTH_TOKEN || "";
2240
const AUDIO_URL = __ENV.AUDIO_URL || "https://dpgr.am/spacewalk.wav";
2341
const CHUNK_SIZE = 4096;
2442
const CHUNK_INTERVAL_MS = 100;
43+
const SESSION_DURATION_MS = 5 * 60 * 1000;
2544

2645
export function setup() {
2746
const res = http.get(AUDIO_URL, { responseType: "binary" });
2847
check(res, { "audio fetch successful": (r) => r.status === 200 });
2948
return { audioData: res.body };
3049
}
3150

32-
export default function (data) {
51+
function runSession(data) {
3352
const url = `${API_URL}/listen?provider=deepgram&language=en&encoding=linear16&sample_rate=16000`;
3453
const params = {
3554
headers: AUTH_TOKEN ? { Authorization: `Bearer ${AUTH_TOKEN}` } : {},
3655
};
3756

3857
const startTime = Date.now();
3958
let firstTranscriptTime = null;
40-
let audioSendComplete = false;
59+
let loopCount = 0;
4160

4261
const res = ws.connect(url, params, function (socket) {
4362
socket.on("open", () => {
@@ -52,9 +71,9 @@ export default function (data) {
5271
const chunk = audioBytes.slice(offset, end);
5372
socket.sendBinary(chunk.buffer);
5473
offset = end;
55-
} else if (!audioSendComplete) {
56-
audioSendComplete = true;
57-
socket.send(JSON.stringify({ type: "CloseStream" }));
74+
} else {
75+
offset = 0;
76+
loopCount++;
5877
}
5978
}, CHUNK_INTERVAL_MS);
6079

@@ -74,14 +93,6 @@ export default function (data) {
7493
firstTranscriptTime = Date.now();
7594
wsFirstTranscriptLatency.add(firstTranscriptTime - startTime);
7695
}
77-
78-
const transcript =
79-
response.channel?.alternatives?.[0]?.transcript || "";
80-
if (transcript && response.is_final) {
81-
console.log(`[transcript] ${transcript}`);
82-
}
83-
} else if (response.type === "Metadata") {
84-
socket.close();
8596
}
8697
} catch (e) {
8798
// ignore non-JSON messages
@@ -90,7 +101,8 @@ export default function (data) {
90101

91102
socket.on("error", (e) => {
92103
if (e.error() !== "websocket: close sent") {
93-
console.log("Error:", e.error());
104+
wsErrors.add(1);
105+
console.log(`[VU ${__VU}] Error: ${e.error()}`);
94106
}
95107
});
96108

@@ -101,12 +113,184 @@ export default function (data) {
101113
socket.setTimeout(() => {
102114
socket.send(JSON.stringify({ type: "CloseStream" }));
103115
socket.close();
104-
}, 30000);
116+
}, SESSION_DURATION_MS);
105117
});
106118

119+
const success = res && res.status === 101;
120+
wsConnectionSuccess.add(success ? 1 : 0);
121+
107122
check(res, {
108123
"WebSocket upgrade successful": (r) => r && r.status === 101,
109124
});
110125

111-
sleep(1);
126+
return success;
127+
}
128+
129+
export default function (data) {
130+
const success = runSession(data);
131+
132+
if (!success) {
133+
wsReconnects.add(1);
134+
sleep(5);
135+
} else {
136+
sleep(1);
137+
}
138+
}
139+
140+
export function handleSummary(data) {
141+
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
142+
const filename = `stt-live-${timestamp}.json`;
143+
144+
const flyMetrics = fetchFlyMetrics(data.state.testRunDurationMs);
145+
146+
const report = {
147+
timestamp: new Date().toISOString(),
148+
duration_ms: data.state.testRunDurationMs,
149+
vus_max: data.state.vusMax,
150+
github: GITHUB_RUN_ID
151+
? {
152+
run_id: GITHUB_RUN_ID,
153+
repository: GITHUB_REPOSITORY,
154+
url: `https://github.com/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}`,
155+
}
156+
: null,
157+
client: {
158+
connections: {
159+
total: data.metrics.ws_connections?.values?.count || 0,
160+
success_rate: data.metrics.ws_connection_success?.values?.rate || 0,
161+
errors: data.metrics.ws_errors?.values?.count || 0,
162+
reconnects: data.metrics.ws_reconnects?.values?.count || 0,
163+
},
164+
transcripts: {
165+
received: data.metrics.ws_transcripts_received?.values?.count || 0,
166+
first_latency_avg_ms:
167+
data.metrics.ws_first_transcript_latency?.values?.avg || 0,
168+
first_latency_p95_ms:
169+
data.metrics.ws_first_transcript_latency?.values?.["p(95)"] || 0,
170+
},
171+
connection_duration: {
172+
avg_ms: data.metrics.ws_connection_duration?.values?.avg || 0,
173+
p95_ms: data.metrics.ws_connection_duration?.values?.["p(95)"] || 0,
174+
},
175+
checks_passed_rate: data.metrics.checks?.values?.rate || 0,
176+
},
177+
server: flyMetrics,
178+
thresholds: Object.fromEntries(
179+
Object.entries(data.metrics)
180+
.filter(([_, v]) => v.thresholds)
181+
.map(([k, v]) => [
182+
k,
183+
{ ok: Object.values(v.thresholds).every((t) => t.ok) },
184+
]),
185+
),
186+
};
187+
188+
return {
189+
stdout: textSummary(report),
190+
[filename]: JSON.stringify(report, null, 2),
191+
};
192+
}
193+
194+
function fetchFlyMetrics(durationMs) {
195+
if (!FLY_ORG || !FLY_APP || !FLY_TOKEN) {
196+
return { error: "FLY_ORG, FLY_APP, or FLY_TOKEN not set" };
197+
}
198+
199+
const endTime = Math.floor(Date.now() / 1000);
200+
const startTime = endTime - Math.ceil(durationMs / 1000);
201+
const step = Math.max(60, Math.floor(durationMs / 1000 / 100));
202+
203+
const queries = {
204+
cpu_usage: `avg(rate(fly_instance_cpu{app="${FLY_APP}", mode!="idle"}[1m]))`,
205+
memory_used_bytes: `avg(fly_instance_memory_mem_total{app="${FLY_APP}"} - fly_instance_memory_mem_available{app="${FLY_APP}"})`,
206+
memory_total_bytes: `avg(fly_instance_memory_mem_total{app="${FLY_APP}"})`,
207+
concurrency: `avg(fly_app_concurrency{app="${FLY_APP}"})`,
208+
net_recv_bytes: `sum(increase(fly_instance_net_recv_bytes{app="${FLY_APP}", device="eth0"}[${Math.ceil(durationMs / 1000)}s]))`,
209+
net_sent_bytes: `sum(increase(fly_instance_net_sent_bytes{app="${FLY_APP}", device="eth0"}[${Math.ceil(durationMs / 1000)}s]))`,
210+
};
211+
212+
const baseUrl = `https://api.fly.io/prometheus/${FLY_ORG}/api/v1/query_range`;
213+
const headers = { Authorization: `Bearer ${FLY_TOKEN}` };
214+
215+
const results = {};
216+
217+
for (const [name, query] of Object.entries(queries)) {
218+
try {
219+
const url = `${baseUrl}?query=${encodeURIComponent(query)}&start=${startTime}&end=${endTime}&step=${step}`;
220+
const res = http.get(url, { headers });
221+
222+
if (res.status === 200) {
223+
const data = JSON.parse(res.body);
224+
const values = data.data?.result?.[0]?.values || [];
225+
226+
if (values.length > 0) {
227+
const nums = values
228+
.map((v) => parseFloat(v[1]))
229+
.filter((n) => !isNaN(n));
230+
results[name] = {
231+
avg: nums.reduce((a, b) => a + b, 0) / nums.length,
232+
max: Math.max(...nums),
233+
min: Math.min(...nums),
234+
};
235+
}
236+
}
237+
} catch (e) {
238+
results[name] = { error: e.message };
239+
}
240+
}
241+
242+
return results;
243+
}
244+
245+
function textSummary(report) {
246+
const c = report.client;
247+
const s = report.server;
248+
249+
const lines = [
250+
"=== STT WebSocket Stability Test Summary ===",
251+
"",
252+
`Duration: ${formatDuration(report.duration_ms)}`,
253+
`VUs: ${report.vus_max}`,
254+
"",
255+
"── Client Metrics ──",
256+
"Connections:",
257+
` Total: ${c.connections.total}`,
258+
` Success Rate: ${(c.connections.success_rate * 100).toFixed(2)}%`,
259+
` Errors: ${c.connections.errors}`,
260+
` Reconnects: ${c.connections.reconnects}`,
261+
"",
262+
"Transcripts:",
263+
` Received: ${c.transcripts.received}`,
264+
` First Latency (avg): ${c.transcripts.first_latency_avg_ms.toFixed(0)}ms`,
265+
"",
266+
"Connection Duration:",
267+
` Avg: ${formatDuration(c.connection_duration.avg_ms)}`,
268+
` P95: ${formatDuration(c.connection_duration.p95_ms)}`,
269+
"",
270+
`Checks: ${(c.checks_passed_rate * 100).toFixed(2)}% passed`,
271+
];
272+
273+
if (s && !s.error) {
274+
lines.push(
275+
"",
276+
"── Server Metrics (Fly.io) ──",
277+
`CPU Usage: avg=${formatPercent(s.cpu_usage?.avg)}, max=${formatPercent(s.cpu_usage?.max)}`,
278+
`Memory: avg=${formatBytes(s.memory_used_bytes?.avg)}/${formatBytes(s.memory_total_bytes?.avg)}`,
279+
`Concurrency: avg=${s.concurrency?.avg?.toFixed(1) || "N/A"}, max=${s.concurrency?.max?.toFixed(0) || "N/A"}`,
280+
`Network: recv=${formatBytes(s.net_recv_bytes?.avg)}, sent=${formatBytes(s.net_sent_bytes?.avg)}`,
281+
);
282+
} else if (s?.error) {
283+
lines.push("", `── Server Metrics: ${s.error} ──`);
284+
}
285+
286+
lines.push("");
287+
return lines.join("\n");
288+
}
289+
290+
function formatDuration(ms) {
291+
if (!ms) return "0s";
292+
if (ms < 1000) return `${ms.toFixed(0)}ms`;
293+
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
294+
if (ms < 3600000) return `${(ms / 60000).toFixed(1)}m`;
295+
return `${(ms / 3600000).toFixed(2)}h`;
112296
}

apps/web/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
"@unpic/react": "^1.0.1",
4141
"drizzle-orm": "^0.44.7",
4242
"exa-js": "^1.10.2",
43+
"jszip": "^3.10.1",
4344
"lucide-react": "^0.544.0",
4445
"mdx-mermaid": "^2.0.3",
4546
"mermaid": "^11.12.2",

apps/web/src/env.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ export const env = createEnv({
2121
DEEPGRAM_API_KEY: z.string().min(1),
2222

2323
RESTATE_INGRESS_URL: z.string().min(1),
24+
25+
GITHUB_TOKEN: z.string().optional(),
2426
},
2527

2628
clientPrefix: "VITE_",

0 commit comments

Comments
 (0)