11import { check , sleep } from "k6" ;
22import http from "k6/http" ;
3- import { Counter , Trend } from "k6/metrics" ;
3+ import { Counter , Rate , Trend } from "k6/metrics" ;
44import 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+
613const wsConnections = new Counter ( "ws_connections" ) ;
714const wsTranscripts = new Counter ( "ws_transcripts_received" ) ;
15+ const wsErrors = new Counter ( "ws_errors" ) ;
16+ const wsReconnects = new Counter ( "ws_reconnects" ) ;
817const wsConnectionDuration = new Trend ( "ws_connection_duration" ) ;
918const 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
1124export 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 || "";
2240const AUDIO_URL = __ENV . AUDIO_URL || "https://dpgr.am/spacewalk.wav" ;
2341const CHUNK_SIZE = 4096 ;
2442const CHUNK_INTERVAL_MS = 100 ;
43+ const SESSION_DURATION_MS = 5 * 60 * 1000 ;
2544
2645export 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}
0 commit comments