@telnyx/voice-agent-tester 0.4.3 → 0.4.5
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.
- package/CHANGELOG.md +23 -0
- package/README.md +185 -161
- package/applications/elevenlabs.yaml +1 -1
- package/javascript/audio_input_hooks.js +89 -19
- package/javascript/audio_output_hooks.js +92 -2
- package/package.json +1 -1
- package/src/index.js +79 -28
- package/src/report.js +169 -90
- package/src/voice-agent-tester.js +43 -7
- package/tests/integration.test.js +4 -3
- package/tests/voice-agent-tester.test.js +133 -0
|
@@ -526,10 +526,98 @@ class AudioElementMonitor {
|
|
|
526
526
|
|
|
527
527
|
console.log(`Started monitoring programmatic audio element: ${elementId}`);
|
|
528
528
|
} catch (error) {
|
|
529
|
-
console.error(`Failed to monitor programmatic audio element ${elementId}:`, error);
|
|
529
|
+
console.error(`Failed to monitor programmatic audio element ${elementId} via analyser:`, error.message);
|
|
530
|
+
console.log(`Falling back to event-based monitoring for ${elementId}`);
|
|
531
|
+
this.monitorViaEvents(audioElement, elementId);
|
|
530
532
|
}
|
|
531
533
|
}
|
|
532
534
|
|
|
535
|
+
/**
|
|
536
|
+
* Fallback monitoring using audio element events (timeupdate/playing/pause).
|
|
537
|
+
* Used when AudioContext-based monitoring fails (e.g., when the audio element
|
|
538
|
+
* is already connected to another AudioContext via MediaStreamDestination).
|
|
539
|
+
*/
|
|
540
|
+
monitorViaEvents(audioElement, elementId) {
|
|
541
|
+
const monitorData = {
|
|
542
|
+
element: audioElement,
|
|
543
|
+
source: null,
|
|
544
|
+
analyser: null,
|
|
545
|
+
dataArray: null,
|
|
546
|
+
isPlaying: false,
|
|
547
|
+
lastAudioTime: 0,
|
|
548
|
+
silenceThreshold: 10,
|
|
549
|
+
checkInterval: null,
|
|
550
|
+
isProgrammatic: true,
|
|
551
|
+
eventBased: true
|
|
552
|
+
};
|
|
553
|
+
|
|
554
|
+
this.monitoredElements.set(elementId, monitorData);
|
|
555
|
+
|
|
556
|
+
// Use timeupdate to detect audio activity — fires ~4x/sec during playback
|
|
557
|
+
let lastTimeUpdate = 0;
|
|
558
|
+
let silenceTimeoutId = null;
|
|
559
|
+
const SILENCE_DELAY = 1500; // ms of no timeupdate before declaring silence
|
|
560
|
+
|
|
561
|
+
const resetSilenceTimer = () => {
|
|
562
|
+
if (silenceTimeoutId) clearTimeout(silenceTimeoutId);
|
|
563
|
+
silenceTimeoutId = setTimeout(() => {
|
|
564
|
+
if (monitorData.isPlaying) {
|
|
565
|
+
monitorData.isPlaying = false;
|
|
566
|
+
this.dispatchAudioEvent('audiostop', elementId, audioElement);
|
|
567
|
+
if (typeof window.__publishEvent === 'function') {
|
|
568
|
+
window.__publishEvent('audiostop', { elementId, timestamp: Date.now() });
|
|
569
|
+
}
|
|
570
|
+
console.log(`Audio stopped (event-based): ${elementId}`);
|
|
571
|
+
}
|
|
572
|
+
}, SILENCE_DELAY);
|
|
573
|
+
};
|
|
574
|
+
|
|
575
|
+
audioElement.addEventListener('timeupdate', () => {
|
|
576
|
+
const now = Date.now();
|
|
577
|
+
// timeupdate fires even when seeking; only count if currentTime advances
|
|
578
|
+
if (audioElement.currentTime > 0 && now - lastTimeUpdate > 50) {
|
|
579
|
+
lastTimeUpdate = now;
|
|
580
|
+
monitorData.lastAudioTime = now;
|
|
581
|
+
|
|
582
|
+
if (!monitorData.isPlaying) {
|
|
583
|
+
monitorData.isPlaying = true;
|
|
584
|
+
this.dispatchAudioEvent('audiostart', elementId, audioElement);
|
|
585
|
+
if (typeof window.__publishEvent === 'function') {
|
|
586
|
+
window.__publishEvent('audiostart', { elementId, timestamp: Date.now() });
|
|
587
|
+
}
|
|
588
|
+
console.log(`Audio started (event-based): ${elementId}`);
|
|
589
|
+
}
|
|
590
|
+
resetSilenceTimer();
|
|
591
|
+
}
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
audioElement.addEventListener('pause', () => {
|
|
595
|
+
if (monitorData.isPlaying) {
|
|
596
|
+
monitorData.isPlaying = false;
|
|
597
|
+
if (silenceTimeoutId) clearTimeout(silenceTimeoutId);
|
|
598
|
+
this.dispatchAudioEvent('audiostop', elementId, audioElement);
|
|
599
|
+
if (typeof window.__publishEvent === 'function') {
|
|
600
|
+
window.__publishEvent('audiostop', { elementId, timestamp: Date.now() });
|
|
601
|
+
}
|
|
602
|
+
console.log(`Audio stopped (event-based, pause): ${elementId}`);
|
|
603
|
+
}
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
audioElement.addEventListener('ended', () => {
|
|
607
|
+
if (monitorData.isPlaying) {
|
|
608
|
+
monitorData.isPlaying = false;
|
|
609
|
+
if (silenceTimeoutId) clearTimeout(silenceTimeoutId);
|
|
610
|
+
this.dispatchAudioEvent('audiostop', elementId, audioElement);
|
|
611
|
+
if (typeof window.__publishEvent === 'function') {
|
|
612
|
+
window.__publishEvent('audiostop', { elementId, timestamp: Date.now() });
|
|
613
|
+
}
|
|
614
|
+
console.log(`Audio stopped (event-based, ended): ${elementId}`);
|
|
615
|
+
}
|
|
616
|
+
});
|
|
617
|
+
|
|
618
|
+
console.log(`Started event-based monitoring for programmatic audio element: ${elementId}`);
|
|
619
|
+
}
|
|
620
|
+
|
|
533
621
|
monitorAudioElement(audioElement, elementId) {
|
|
534
622
|
if (!this.audioContext) {
|
|
535
623
|
console.warn("AudioContext not available, cannot monitor audio");
|
|
@@ -564,7 +652,9 @@ class AudioElementMonitor {
|
|
|
564
652
|
|
|
565
653
|
console.log(`Started monitoring audio element: ${elementId}`);
|
|
566
654
|
} catch (error) {
|
|
567
|
-
console.error(`Failed to monitor audio element ${elementId}:`, error);
|
|
655
|
+
console.error(`Failed to monitor audio element ${elementId} via analyser:`, error.message);
|
|
656
|
+
console.log(`Falling back to event-based monitoring for ${elementId}`);
|
|
657
|
+
this.monitorViaEvents(audioElement, elementId);
|
|
568
658
|
}
|
|
569
659
|
}
|
|
570
660
|
|
package/package.json
CHANGED
package/src/index.js
CHANGED
|
@@ -109,14 +109,7 @@ function getCompareRequiredParams(argv) {
|
|
|
109
109
|
}
|
|
110
110
|
break;
|
|
111
111
|
case 'elevenlabs':
|
|
112
|
-
|
|
113
|
-
missing.push({
|
|
114
|
-
key: 'branchId',
|
|
115
|
-
flag: '--branch-id',
|
|
116
|
-
description: 'ElevenLabs branch ID',
|
|
117
|
-
hint: 'In the ElevenLabs Dashboard, go to Agents, select your target agent, then click the dropdown next to Publish and select "Copy shareable link". This copies the demo link containing your branch ID.'
|
|
118
|
-
});
|
|
119
|
-
}
|
|
112
|
+
// branchId is optional — the talk-to URL works with just agent_id
|
|
120
113
|
break;
|
|
121
114
|
// retell and others: no extra params needed yet
|
|
122
115
|
}
|
|
@@ -134,8 +127,22 @@ function getCompareTemplateParams(argv) {
|
|
|
134
127
|
switch (argv.provider) {
|
|
135
128
|
case 'vapi':
|
|
136
129
|
return { shareKey: argv.shareKey };
|
|
130
|
+
default:
|
|
131
|
+
return {};
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Get provider-specific extra query parameters to append to the comparison URL.
|
|
137
|
+
* Unlike template params, these are appended as-is (not substituted into {{...}} placeholders).
|
|
138
|
+
*
|
|
139
|
+
* @param {Object} argv - Parsed CLI arguments
|
|
140
|
+
* @returns {Object} Key-value pairs to append as query parameters
|
|
141
|
+
*/
|
|
142
|
+
function getCompareExtraQueryParams(argv) {
|
|
143
|
+
switch (argv.provider) {
|
|
137
144
|
case 'elevenlabs':
|
|
138
|
-
return {
|
|
145
|
+
return argv.branchId ? { branch_id: argv.branchId } : {};
|
|
139
146
|
default:
|
|
140
147
|
return {};
|
|
141
148
|
}
|
|
@@ -295,7 +302,7 @@ const argv = yargs(hideBin(process.argv))
|
|
|
295
302
|
})
|
|
296
303
|
.option('branch-id', {
|
|
297
304
|
type: 'string',
|
|
298
|
-
description: 'ElevenLabs branch ID for direct widget testing (
|
|
305
|
+
description: 'ElevenLabs branch ID for direct widget testing (optional, appended to demo URL when provided)'
|
|
299
306
|
})
|
|
300
307
|
.option('assistant-id', {
|
|
301
308
|
type: 'string',
|
|
@@ -327,6 +334,11 @@ const argv = yargs(hideBin(process.argv))
|
|
|
327
334
|
description: 'Volume level for audio input (0.0 to 1.0)',
|
|
328
335
|
default: 1.0
|
|
329
336
|
})
|
|
337
|
+
.option('retries', {
|
|
338
|
+
type: 'number',
|
|
339
|
+
description: 'Number of retries for failed test runs (0 = no retries)',
|
|
340
|
+
default: 0
|
|
341
|
+
})
|
|
330
342
|
.help()
|
|
331
343
|
.argv;
|
|
332
344
|
|
|
@@ -409,22 +421,49 @@ async function runBenchmark({ applications, scenarios, repeat, concurrency, argv
|
|
|
409
421
|
audioVolume: argv.audioVolume
|
|
410
422
|
});
|
|
411
423
|
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
424
|
+
const maxAttempts = (argv.retries || 0) + 1;
|
|
425
|
+
|
|
426
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
427
|
+
// Create a fresh tester for each attempt (after first, original tester is closed)
|
|
428
|
+
const currentTester = attempt === 1 ? tester : new VoiceAgentTester({
|
|
429
|
+
verbose: argv.verbose,
|
|
430
|
+
headless: argv.headless,
|
|
431
|
+
assetsServerUrl: argv.assetsServer,
|
|
432
|
+
reportGenerator: reportGenerator,
|
|
433
|
+
record: argv.record,
|
|
434
|
+
debug: argv.debug,
|
|
435
|
+
audioUrl: argv.audioUrl,
|
|
436
|
+
audioVolume: argv.audioVolume
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
try {
|
|
440
|
+
await currentTester.runScenario(targetUrl, app.steps, scenario.steps, app.name, scenario.name, repetition);
|
|
441
|
+
console.log(`✅ Completed successfully (Run ${runNumber}/${totalRuns})`);
|
|
442
|
+
return { success: true };
|
|
443
|
+
} catch (error) {
|
|
444
|
+
const shortMessage = error.message.split('\n')[0];
|
|
445
|
+
|
|
446
|
+
if (attempt < maxAttempts) {
|
|
447
|
+
console.warn(`\n⚠️ Attempt ${attempt}/${maxAttempts} failed: ${shortMessage}`);
|
|
448
|
+
console.warn(`🔄 Retrying in 3s... (${maxAttempts - attempt} retries left)\n`);
|
|
449
|
+
await new Promise(r => setTimeout(r, 3000));
|
|
450
|
+
continue;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// Final attempt failed
|
|
454
|
+
const errorInfo = {
|
|
455
|
+
app: app.name,
|
|
456
|
+
scenario: scenario.name,
|
|
457
|
+
repetition,
|
|
458
|
+
error: shortMessage
|
|
459
|
+
};
|
|
460
|
+
// Print full diagnostics here (only place they appear)
|
|
461
|
+
console.error(`❌ Error (Run ${runNumber}/${totalRuns}):\n${error.message}`);
|
|
462
|
+
if (maxAttempts > 1) {
|
|
463
|
+
console.error(` Failed after ${maxAttempts} attempts`);
|
|
464
|
+
}
|
|
465
|
+
return { success: false, error: errorInfo };
|
|
466
|
+
}
|
|
428
467
|
}
|
|
429
468
|
}
|
|
430
469
|
|
|
@@ -678,7 +717,19 @@ async function main() {
|
|
|
678
717
|
throw new Error(`Provider application config not found: ${providerAppPath}\nPlease create applications/${argv.provider}.yaml for direct provider benchmarking.`);
|
|
679
718
|
}
|
|
680
719
|
|
|
681
|
-
const
|
|
720
|
+
const providerApp = loadApplicationConfig(providerAppPath, providerParams);
|
|
721
|
+
|
|
722
|
+
// Append optional extra query parameters (e.g. branch_id for ElevenLabs)
|
|
723
|
+
const extraQueryParams = getCompareExtraQueryParams(argv);
|
|
724
|
+
if (providerApp.url && Object.keys(extraQueryParams).length > 0) {
|
|
725
|
+
const url = new URL(providerApp.url);
|
|
726
|
+
for (const [key, value] of Object.entries(extraQueryParams)) {
|
|
727
|
+
url.searchParams.set(key, value);
|
|
728
|
+
}
|
|
729
|
+
providerApp.url = url.toString();
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
const providerApplications = [providerApp];
|
|
682
733
|
|
|
683
734
|
const providerResults = await runBenchmark({
|
|
684
735
|
applications: providerApplications,
|
|
@@ -730,7 +781,7 @@ async function main() {
|
|
|
730
781
|
telnyxReportGenerator.generateMetricsSummary();
|
|
731
782
|
|
|
732
783
|
// Generate comparison report
|
|
733
|
-
ReportGenerator.generateComparisonSummary(providerReportGenerator, telnyxReportGenerator, argv.provider);
|
|
784
|
+
ReportGenerator.generateComparisonSummary(providerReportGenerator, telnyxReportGenerator, argv.provider, { debug: argv.debug });
|
|
734
785
|
|
|
735
786
|
// Generate CSV reports if requested
|
|
736
787
|
if (argv.report) {
|
package/src/report.js
CHANGED
|
@@ -28,7 +28,7 @@ export class ReportGenerator {
|
|
|
28
28
|
});
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
recordStepMetric(appName, scenarioName, repetition, stepIndex, action, name, value) {
|
|
31
|
+
recordStepMetric(appName, scenarioName, repetition, stepIndex, action, name, value, scenarioStepIndex = null) {
|
|
32
32
|
const key = this._getRunKey(appName, scenarioName, repetition);
|
|
33
33
|
const run = this.runs.get(key);
|
|
34
34
|
|
|
@@ -52,6 +52,26 @@ export class ReportGenerator {
|
|
|
52
52
|
if (!this.stepColumns.get(stepIndex).has(name)) {
|
|
53
53
|
this.stepColumns.get(stepIndex).set(name, `step_${stepIndex + 1}_${action}_${name}`);
|
|
54
54
|
}
|
|
55
|
+
|
|
56
|
+
// Track scenario step index for cross-provider comparison alignment
|
|
57
|
+
if (scenarioStepIndex !== null) {
|
|
58
|
+
if (!this.scenarioStepMap) {
|
|
59
|
+
this.scenarioStepMap = new Map();
|
|
60
|
+
}
|
|
61
|
+
// Map absolute stepIndex -> scenarioStepIndex (1-based)
|
|
62
|
+
this.scenarioStepMap.set(stepIndex, scenarioStepIndex);
|
|
63
|
+
|
|
64
|
+
// Track scenario-based column names for comparison display
|
|
65
|
+
if (!this.scenarioStepColumns) {
|
|
66
|
+
this.scenarioStepColumns = new Map();
|
|
67
|
+
}
|
|
68
|
+
if (!this.scenarioStepColumns.has(scenarioStepIndex)) {
|
|
69
|
+
this.scenarioStepColumns.set(scenarioStepIndex, new Map());
|
|
70
|
+
}
|
|
71
|
+
if (!this.scenarioStepColumns.get(scenarioStepIndex).has(name)) {
|
|
72
|
+
this.scenarioStepColumns.get(scenarioStepIndex).set(name, `scenario_step_${scenarioStepIndex}_${action}_${name}`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
55
75
|
}
|
|
56
76
|
|
|
57
77
|
endRun(appName, scenarioName, repetition, success = true) {
|
|
@@ -285,119 +305,178 @@ export class ReportGenerator {
|
|
|
285
305
|
return result;
|
|
286
306
|
}
|
|
287
307
|
|
|
308
|
+
/**
|
|
309
|
+
* Get aggregated metrics keyed by scenario step index for cross-provider comparison.
|
|
310
|
+
* Returns a Map of scenarioStepIndex -> { metricName -> { avg, min, max, p50, columnName } }
|
|
311
|
+
*/
|
|
312
|
+
getAggregatedMetricsByScenarioStep() {
|
|
313
|
+
const result = new Map();
|
|
314
|
+
|
|
315
|
+
// Build reverse map: absolute stepIndex -> scenarioStepIndex
|
|
316
|
+
const scenarioStepMap = this.scenarioStepMap || new Map();
|
|
317
|
+
|
|
318
|
+
// Collect values grouped by scenarioStepIndex
|
|
319
|
+
const grouped = new Map(); // scenarioStepIndex -> metricName -> values[]
|
|
320
|
+
|
|
321
|
+
this.allRunsData.forEach(run => {
|
|
322
|
+
run.stepMetrics.forEach((metrics, stepIndex) => {
|
|
323
|
+
const scenarioIdx = scenarioStepMap.get(stepIndex);
|
|
324
|
+
if (scenarioIdx == null) return; // skip steps without scenario mapping
|
|
325
|
+
|
|
326
|
+
if (!grouped.has(scenarioIdx)) {
|
|
327
|
+
grouped.set(scenarioIdx, new Map());
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
metrics.forEach((value, metricName) => {
|
|
331
|
+
if (!grouped.get(scenarioIdx).has(metricName)) {
|
|
332
|
+
grouped.get(scenarioIdx).set(metricName, []);
|
|
333
|
+
}
|
|
334
|
+
grouped.get(scenarioIdx).get(metricName).push(value);
|
|
335
|
+
});
|
|
336
|
+
});
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
grouped.forEach((metricMap, scenarioIdx) => {
|
|
340
|
+
const stepResult = new Map();
|
|
341
|
+
|
|
342
|
+
metricMap.forEach((values, metricName) => {
|
|
343
|
+
if (values.length > 0) {
|
|
344
|
+
const sum = values.reduce((a, b) => a + b, 0);
|
|
345
|
+
const avg = sum / values.length;
|
|
346
|
+
const min = Math.min(...values);
|
|
347
|
+
const max = Math.max(...values);
|
|
348
|
+
|
|
349
|
+
const sortedValues = [...values].sort((a, b) => a - b);
|
|
350
|
+
let p50;
|
|
351
|
+
if (sortedValues.length % 2 === 0) {
|
|
352
|
+
p50 = (sortedValues[sortedValues.length / 2 - 1] + sortedValues[sortedValues.length / 2]) / 2;
|
|
353
|
+
} else {
|
|
354
|
+
p50 = sortedValues[Math.floor(sortedValues.length / 2)];
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const columnName = this.scenarioStepColumns?.get(scenarioIdx)?.get(metricName) ||
|
|
358
|
+
`scenario_step_${scenarioIdx}_${metricName}`;
|
|
359
|
+
|
|
360
|
+
stepResult.set(metricName, { avg, min, max, p50, columnName });
|
|
361
|
+
}
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
result.set(scenarioIdx, stepResult);
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
return result;
|
|
368
|
+
}
|
|
369
|
+
|
|
288
370
|
/**
|
|
289
371
|
* Generate a comparison summary between two providers.
|
|
372
|
+
* Aligns metrics by scenario step index so that identical scenario steps
|
|
373
|
+
* are compared regardless of different application setup steps.
|
|
290
374
|
* @param {ReportGenerator} providerReport - Report from the provider benchmark
|
|
291
375
|
* @param {ReportGenerator} telnyxReport - Report from the Telnyx benchmark
|
|
292
376
|
* @param {string} providerName - Name of the external provider
|
|
293
377
|
*/
|
|
294
|
-
static generateComparisonSummary(providerReport, telnyxReport, providerName) {
|
|
378
|
+
static generateComparisonSummary(providerReport, telnyxReport, providerName, { debug = false } = {}) {
|
|
295
379
|
console.log('\n' + '='.repeat(80));
|
|
296
|
-
console.log('📊 COMPARISON
|
|
380
|
+
console.log('📊 COMPARISON: ' + providerName.toUpperCase() + ' vs TELNYX');
|
|
297
381
|
console.log('='.repeat(80));
|
|
298
382
|
|
|
299
|
-
|
|
300
|
-
const
|
|
383
|
+
// Use scenario-step-aligned metrics for comparison
|
|
384
|
+
const providerMetrics = providerReport.getAggregatedMetricsByScenarioStep();
|
|
385
|
+
const telnyxMetrics = telnyxReport.getAggregatedMetricsByScenarioStep();
|
|
301
386
|
|
|
302
|
-
// Find
|
|
303
|
-
const
|
|
387
|
+
// Find matched scenario steps (present in both providers)
|
|
388
|
+
const allScenarioSteps = new Set([
|
|
304
389
|
...providerMetrics.keys(),
|
|
305
390
|
...telnyxMetrics.keys()
|
|
306
391
|
]);
|
|
307
|
-
const sortedIndices = Array.from(
|
|
392
|
+
const sortedIndices = Array.from(allScenarioSteps).sort((a, b) => a - b);
|
|
393
|
+
|
|
394
|
+
// Collect matched latencies
|
|
395
|
+
const providerLatencies = [];
|
|
396
|
+
const telnyxLatencies = [];
|
|
397
|
+
const perResponse = []; // for debug output
|
|
308
398
|
|
|
309
|
-
|
|
310
|
-
|
|
399
|
+
sortedIndices.forEach(scenarioStep => {
|
|
400
|
+
const providerElapsed = providerMetrics.get(scenarioStep)?.get('elapsed_time');
|
|
401
|
+
const telnyxElapsed = telnyxMetrics.get(scenarioStep)?.get('elapsed_time');
|
|
402
|
+
|
|
403
|
+
if (providerElapsed && telnyxElapsed) {
|
|
404
|
+
providerLatencies.push(providerElapsed.avg);
|
|
405
|
+
telnyxLatencies.push(telnyxElapsed.avg);
|
|
406
|
+
perResponse.push({
|
|
407
|
+
providerAvg: providerElapsed.avg,
|
|
408
|
+
telnyxAvg: telnyxElapsed.avg,
|
|
409
|
+
columnName: providerElapsed.columnName || telnyxElapsed.columnName
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
if (providerLatencies.length === 0) {
|
|
415
|
+
console.log('\n⚠️ No comparable metrics found between providers.');
|
|
416
|
+
console.log('='.repeat(80));
|
|
311
417
|
return;
|
|
312
418
|
}
|
|
313
419
|
|
|
314
|
-
//
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
const
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
if (providerElapsed && telnyxElapsed) {
|
|
344
|
-
const diff = telnyxElapsed.avg - providerElapsed.avg;
|
|
345
|
-
const pct = ((diff / providerElapsed.avg) * 100).toFixed(1);
|
|
346
|
-
delta = diff > 0 ? `+${Math.round(diff)}ms` : `${Math.round(diff)}ms`;
|
|
347
|
-
|
|
348
|
-
if (Math.abs(diff) < 50) {
|
|
349
|
-
winner = '≈ Tie';
|
|
350
|
-
} else if (diff < 0) {
|
|
351
|
-
winner = '🏆 Telnyx';
|
|
352
|
-
} else {
|
|
353
|
-
winner = `🏆 ${providerName}`;
|
|
354
|
-
}
|
|
355
|
-
delta += ` (${pct}%)`;
|
|
420
|
+
// Debug: show per-response breakdown
|
|
421
|
+
if (debug && perResponse.length > 0) {
|
|
422
|
+
console.log('\n📈 Per-response breakdown:');
|
|
423
|
+
console.log('-'.repeat(80));
|
|
424
|
+
console.log(
|
|
425
|
+
'Response'.padEnd(40) +
|
|
426
|
+
providerName.padEnd(12) +
|
|
427
|
+
'Telnyx'.padEnd(12) +
|
|
428
|
+
'Delta'.padEnd(16) +
|
|
429
|
+
'Winner'
|
|
430
|
+
);
|
|
431
|
+
console.log('-'.repeat(80));
|
|
432
|
+
|
|
433
|
+
perResponse.forEach((r, i) => {
|
|
434
|
+
const action = (r.columnName || '').replace(/^scenario_step_\d+_/, '');
|
|
435
|
+
const label = `#${i + 1} (${action})`;
|
|
436
|
+
const shortLabel = label.length > 38 ? label.substring(0, 35) + '...' : label;
|
|
437
|
+
|
|
438
|
+
const diff = r.telnyxAvg - r.providerAvg;
|
|
439
|
+
const pct = ((diff / r.providerAvg) * 100).toFixed(1);
|
|
440
|
+
const delta = `${diff > 0 ? '+' : ''}${Math.round(diff)}ms (${pct}%)`;
|
|
441
|
+
|
|
442
|
+
let winner;
|
|
443
|
+
if (Math.abs(diff) < 50) {
|
|
444
|
+
winner = '≈ Tie';
|
|
445
|
+
} else if (diff < 0) {
|
|
446
|
+
winner = '🏆 Telnyx';
|
|
447
|
+
} else {
|
|
448
|
+
winner = `🏆 ${providerName}`;
|
|
356
449
|
}
|
|
357
450
|
|
|
358
451
|
console.log(
|
|
359
|
-
|
|
360
|
-
`${providerAvg}ms`.padEnd(12) +
|
|
361
|
-
`${telnyxAvg}ms`.padEnd(12) +
|
|
362
|
-
delta.padEnd(
|
|
452
|
+
shortLabel.padEnd(40) +
|
|
453
|
+
`${Math.round(r.providerAvg)}ms`.padEnd(12) +
|
|
454
|
+
`${Math.round(r.telnyxAvg)}ms`.padEnd(12) +
|
|
455
|
+
delta.padEnd(16) +
|
|
363
456
|
winner
|
|
364
457
|
);
|
|
365
|
-
}
|
|
366
|
-
});
|
|
367
|
-
|
|
368
|
-
console.log('-'.repeat(80));
|
|
458
|
+
});
|
|
369
459
|
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
sortedIndices.forEach(stepIndex => {
|
|
373
|
-
const providerStep = providerMetrics.get(stepIndex);
|
|
374
|
-
const telnyxStep = telnyxMetrics.get(stepIndex);
|
|
375
|
-
const providerElapsed = providerStep?.get('elapsed_time');
|
|
376
|
-
const telnyxElapsed = telnyxStep?.get('elapsed_time');
|
|
377
|
-
|
|
378
|
-
if (providerElapsed && telnyxElapsed) {
|
|
379
|
-
providerTotal += providerElapsed.avg;
|
|
380
|
-
telnyxTotal += telnyxElapsed.avg;
|
|
381
|
-
comparableSteps++;
|
|
382
|
-
}
|
|
383
|
-
});
|
|
460
|
+
console.log('-'.repeat(80));
|
|
461
|
+
}
|
|
384
462
|
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
463
|
+
// One headline number: average response latency
|
|
464
|
+
const providerAvg = providerLatencies.reduce((a, b) => a + b, 0) / providerLatencies.length;
|
|
465
|
+
const telnyxAvg = telnyxLatencies.reduce((a, b) => a + b, 0) / telnyxLatencies.length;
|
|
466
|
+
const diff = telnyxAvg - providerAvg;
|
|
467
|
+
const pct = ((diff / providerAvg) * 100).toFixed(1);
|
|
468
|
+
|
|
469
|
+
console.log(`\n Average response latency (${providerLatencies.length} matched responses):\n`);
|
|
470
|
+
console.log(` ${providerName.padEnd(16)} ${Math.round(providerAvg)}ms`);
|
|
471
|
+
console.log(` ${'Telnyx'.padEnd(16)} ${Math.round(telnyxAvg)}ms`);
|
|
472
|
+
console.log(` ${'Difference'.padEnd(16)} ${diff > 0 ? '+' : ''}${Math.round(diff)}ms (${pct}%)`);
|
|
473
|
+
|
|
474
|
+
if (Math.abs(diff) < 50) {
|
|
475
|
+
console.log('\n 🤝 Result: Both providers perform similarly');
|
|
476
|
+
} else if (diff < 0) {
|
|
477
|
+
console.log(`\n 🏆 Telnyx is ${Math.abs(pct)}% faster`);
|
|
478
|
+
} else {
|
|
479
|
+
console.log(`\n 🏆 ${providerName} is ${Math.abs(pct)}% faster`);
|
|
401
480
|
}
|
|
402
481
|
|
|
403
482
|
console.log('\n' + '='.repeat(80));
|