Skip to content

Commit ed392a4

Browse files
committed
setup load-testing
1 parent 3689f71 commit ed392a4

File tree

8 files changed

+281
-3
lines changed

8 files changed

+281
-3
lines changed

.github/workflows/k6.yaml

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
on:
2+
workflow_dispatch:
3+
4+
jobs:
5+
load-test:
6+
runs-on: ubuntu-latest
7+
steps:
8+
- uses: actions/checkout@v4
9+
10+
- uses: superfly/flyctl-actions/setup-flyctl@master
11+
12+
- run: flyctl deploy --app hyprnote-api-loadtest --strategy immediate --wait-timeout 120
13+
env:
14+
FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
15+
16+
- run: flyctl scale count 2 --app hyprnote-api-loadtest --yes
17+
env:
18+
FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
19+
20+
- uses: grafana/setup-k6-action@v1
21+
22+
- uses: grafana/run-k6-action@v1
23+
with:
24+
path: apps/k6/scripts/websocket/listen.js
25+
env:
26+
API_URL: wss://hyprnote-api-loadtest.fly.dev
27+
AUTH_TOKEN: ${{ secrets.K6_AUTH_TOKEN }}
28+
29+
- run: flyctl scale count 0 --app hyprnote-api-loadtest --yes
30+
if: always()
31+
env:
32+
FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}

apps/api/src/env.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ export const env = createEnv({
1717
ASSEMBLYAI_API_KEY: z.string().min(1),
1818
SONIOX_API_KEY: z.string().min(1),
1919
POSTHOG_API_KEY: z.string().min(1),
20+
OVERRIDE_AUTH: z.string().optional(),
2021
},
2122
runtimeEnv: Bun.env,
2223
emptyStringAsUndefined: true,

apps/api/src/index.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { logger } from "hono/logger";
1111

1212
import { env } from "./env";
1313
import type { AppBindings } from "./hono-bindings";
14+
import { loadTestOverride } from "./load-test-auth";
1415
import { API_TAGS, routes } from "./routes";
1516
import { sentryMiddleware } from "./sentry/middleware";
1617
import { verifyStripeWebhook } from "./stripe";
@@ -43,11 +44,11 @@ app.use("*", (c, next) => {
4344
return corsMiddleware(c, next);
4445
});
4546

46-
app.use("/chat/completions", requireSupabaseAuth);
47+
app.use("/chat/completions", loadTestOverride, requireSupabaseAuth);
4748
app.use("/webhook/stripe", verifyStripeWebhook);
4849

4950
if (env.NODE_ENV !== "development") {
50-
app.use("/listen", requireSupabaseAuth);
51+
app.use("/listen", loadTestOverride, requireSupabaseAuth);
5152
}
5253

5354
app.route("/", routes);

apps/api/src/load-test-auth.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { createMiddleware } from "hono/factory";
2+
3+
import { env } from "./env";
4+
5+
export const loadTestOverride = createMiddleware<{
6+
Variables: { supabaseUserId: string };
7+
}>(async (c, next) => {
8+
if (env.OVERRIDE_AUTH) {
9+
const token = c.req.header("Authorization")?.replace("Bearer ", "");
10+
if (token === env.OVERRIDE_AUTH) {
11+
c.set("supabaseUserId", "load-test-user");
12+
return next();
13+
}
14+
}
15+
return next();
16+
});

apps/api/src/supabase.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,11 @@ export const requireSupabaseAuth = createMiddleware<{
1616
return c.text("unauthorized", 401);
1717
}
1818

19+
const token = authHeader.replace("Bearer ", "");
1920
const supabaseClient = createClient(env.SUPABASE_URL, env.SUPABASE_ANON_KEY, {
2021
global: { headers: { Authorization: authHeader } },
2122
});
2223

23-
const token = authHeader.replace("Bearer ", "");
2424
const { data, error } = await supabaseClient.auth.getUser(token);
2525

2626
if (error || !data.user) {

apps/k6/package.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"name": "@hypr/k6",
3+
"private": true,
4+
"scripts": {
5+
"test:local": "k6 run scripts/websocket/listen.js --env API_URL=ws://localhost:4000",
6+
"test:loadtest": "k6 run scripts/websocket/listen.js --env API_URL=wss://hyprnote-api-loadtest.fly.dev"
7+
}
8+
}

apps/k6/scripts/llm.js

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import { check, sleep } from "k6";
2+
import http from "k6/http";
3+
import { Counter, Trend } from "k6/metrics";
4+
5+
const completionRequests = new Counter("completion_requests");
6+
const completionSuccess = new Counter("completion_success");
7+
const completionLatency = new Trend("completion_latency");
8+
const streamingTTFB = new Trend("streaming_ttfb");
9+
10+
export const options = {
11+
vus: 5,
12+
duration: "30s",
13+
thresholds: {
14+
completion_success: ["count > 0"],
15+
checks: ["rate > 0.9"],
16+
completion_latency: ["p(95) < 30000"],
17+
},
18+
};
19+
20+
const API_URL = __ENV.API_URL || "http://localhost:4000";
21+
const AUTH_TOKEN = __ENV.AUTH_TOKEN || "";
22+
const STREAMING = __ENV.STREAMING === "true";
23+
24+
const PROMPTS = {
25+
short: "What is 2+2?",
26+
medium:
27+
"Explain the concept of recursion in programming. Include a simple example and mention common use cases.",
28+
long: `You are a helpful assistant analyzing a meeting transcript. Here is the context:
29+
30+
The team discussed the following topics:
31+
1. Q3 roadmap planning - focusing on improving user onboarding experience
32+
2. Technical debt reduction - specifically around the authentication system
33+
3. New feature requests from enterprise customers
34+
4. Performance optimization for the real-time collaboration features
35+
5. Integration with third-party calendar services
36+
37+
Based on this context, please provide:
38+
- A summary of the key discussion points
39+
- Action items that should be tracked
40+
- Any decisions that were made
41+
- Recommended follow-up topics for the next meeting
42+
43+
Keep your response concise but comprehensive.`,
44+
};
45+
46+
const PROMPT_KEYS = Object.keys(PROMPTS);
47+
48+
function getRandomPrompt() {
49+
const key = PROMPT_KEYS[Math.floor(Math.random() * PROMPT_KEYS.length)];
50+
return { type: key, content: PROMPTS[key] };
51+
}
52+
53+
function makeRequest(prompt, stream) {
54+
const url = `${API_URL}/chat/completions`;
55+
const payload = JSON.stringify({
56+
messages: [
57+
{ role: "system", content: "You are a helpful assistant." },
58+
{ role: "user", content: prompt.content },
59+
],
60+
stream,
61+
max_tokens: 256,
62+
});
63+
64+
const params = {
65+
headers: {
66+
"Content-Type": "application/json",
67+
...(AUTH_TOKEN ? { Authorization: `Bearer ${AUTH_TOKEN}` } : {}),
68+
},
69+
timeout: "60s",
70+
};
71+
72+
const startTime = Date.now();
73+
const res = http.post(url, payload, params);
74+
const latency = Date.now() - startTime;
75+
76+
return { res, latency, promptType: prompt.type };
77+
}
78+
79+
export default function () {
80+
const prompt = getRandomPrompt();
81+
completionRequests.add(1);
82+
83+
const { res, latency, promptType } = makeRequest(prompt, STREAMING);
84+
85+
const isSuccess = check(res, {
86+
"status is 200": (r) => r.status === 200,
87+
"has response body": (r) => r.body && r.body.length > 0,
88+
});
89+
90+
if (isSuccess) {
91+
completionSuccess.add(1);
92+
completionLatency.add(latency);
93+
94+
if (STREAMING) {
95+
streamingTTFB.add(res.timings.waiting);
96+
}
97+
98+
console.log(
99+
`[${promptType}] ${STREAMING ? "stream" : "sync"} completed in ${latency}ms`,
100+
);
101+
} else {
102+
console.log(
103+
`[${promptType}] failed with status ${res.status}: ${res.body}`,
104+
);
105+
}
106+
107+
sleep(1);
108+
}

apps/k6/scripts/stt-live.js

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import { check, sleep } from "k6";
2+
import http from "k6/http";
3+
import { Counter, Trend } from "k6/metrics";
4+
import ws from "k6/ws";
5+
6+
const wsConnections = new Counter("ws_connections");
7+
const wsTranscripts = new Counter("ws_transcripts_received");
8+
const wsConnectionDuration = new Trend("ws_connection_duration");
9+
const wsFirstTranscriptLatency = new Trend("ws_first_transcript_latency");
10+
11+
export const options = {
12+
vus: 10,
13+
duration: "30s",
14+
thresholds: {
15+
ws_connections: ["count > 0"],
16+
checks: ["rate > 0.9"],
17+
},
18+
};
19+
20+
const API_URL = __ENV.API_URL || "ws://localhost:4000";
21+
const AUTH_TOKEN = __ENV.AUTH_TOKEN || "";
22+
const AUDIO_URL = __ENV.AUDIO_URL || "https://dpgr.am/spacewalk.wav";
23+
const CHUNK_SIZE = 4096;
24+
const CHUNK_INTERVAL_MS = 100;
25+
26+
export function setup() {
27+
const res = http.get(AUDIO_URL, { responseType: "binary" });
28+
check(res, { "audio fetch successful": (r) => r.status === 200 });
29+
return { audioData: res.body };
30+
}
31+
32+
export default function (data) {
33+
const url = `${API_URL}/listen?provider=deepgram&language=en&encoding=linear16&sample_rate=16000`;
34+
const params = {
35+
headers: AUTH_TOKEN ? { Authorization: `Bearer ${AUTH_TOKEN}` } : {},
36+
};
37+
38+
const startTime = Date.now();
39+
let firstTranscriptTime = null;
40+
let audioSendComplete = false;
41+
42+
const res = ws.connect(url, params, function (socket) {
43+
socket.on("open", () => {
44+
wsConnections.add(1);
45+
46+
const audioBytes = new Uint8Array(data.audioData);
47+
let offset = 0;
48+
49+
socket.setInterval(() => {
50+
if (offset < audioBytes.length) {
51+
const end = Math.min(offset + CHUNK_SIZE, audioBytes.length);
52+
const chunk = audioBytes.slice(offset, end);
53+
socket.sendBinary(chunk.buffer);
54+
offset = end;
55+
} else if (!audioSendComplete) {
56+
audioSendComplete = true;
57+
socket.send(JSON.stringify({ type: "CloseStream" }));
58+
}
59+
}, CHUNK_INTERVAL_MS);
60+
61+
socket.setInterval(() => {
62+
socket.send(JSON.stringify({ type: "KeepAlive" }));
63+
}, 3000);
64+
});
65+
66+
socket.on("message", (msg) => {
67+
try {
68+
const response = JSON.parse(msg);
69+
70+
if (response.type === "Results") {
71+
wsTranscripts.add(1);
72+
73+
if (firstTranscriptTime === null) {
74+
firstTranscriptTime = Date.now();
75+
wsFirstTranscriptLatency.add(firstTranscriptTime - startTime);
76+
}
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();
85+
}
86+
} catch (e) {
87+
// ignore non-JSON messages
88+
}
89+
});
90+
91+
socket.on("error", (e) => {
92+
if (e.error() !== "websocket: close sent") {
93+
console.log("Error:", e.error());
94+
}
95+
});
96+
97+
socket.on("close", () => {
98+
wsConnectionDuration.add(Date.now() - startTime);
99+
});
100+
101+
socket.setTimeout(() => {
102+
socket.send(JSON.stringify({ type: "CloseStream" }));
103+
socket.close();
104+
}, 30000);
105+
});
106+
107+
check(res, {
108+
"WebSocket upgrade successful": (r) => r && r.status === 101,
109+
});
110+
111+
sleep(1);
112+
}

0 commit comments

Comments
 (0)