( �application/grpc grpc-encodinggzip#!/usr/bin/env node const fs = require('fs'); function generatePerformanceReport(metricsData, outputFile = 'performance-report.html') { const { startTime, endTime, players, totals, timeline, responseTimes } = metricsData; const duration = endTime - startTime; const durationSeconds = Math.round(duration / 1000); // Calculate statistics const stats = { duration: durationSeconds, totalPlayers: players.length, successfulLogins: totals.loginSuccess, loginSuccessRate: (totals.loginSuccess / totals.loginAttempts * 100).toFixed(1), wsSuccessRate: (totals.wsSuccessful / totals.wsConnections * 100).toFixed(1), flagSuccessRate: (totals.flagsCorrect / totals.flagSubmissions * 100).toFixed(1), averageResponseTimes: { login: calculateAverage(responseTimes.login), submit: calculateAverage(responseTimes.submit), challenges: calculateAverage(responseTimes.challenges), staticFiles: calculateAverage(responseTimes.staticFiles) }, percentiles: { login: calculatePercentiles(responseTimes.login), submit: calculatePercentiles(responseTimes.submit) }, staticFileBreakdown: metricsData.staticFileBreakdown ? { 'index.html': calculatePercentiles(metricsData.staticFileBreakdown['index.html'] || []), 'CSS': calculatePercentiles(metricsData.staticFileBreakdown['CSS'] || []), 'banner image': calculatePercentiles(metricsData.staticFileBreakdown['banner image'] || []), 'app.js': calculatePercentiles(metricsData.staticFileBreakdown['app.js'] || []) } : null, throughput: { loginsPerSecond: (totals.loginSuccess / durationSeconds).toFixed(2), flagsPerSecond: (totals.flagsCorrect / durationSeconds).toFixed(2) }, userExperience: calculateUserExperienceMetrics(players, startTime) }; // Error analysis const errorsByType = {}; totals.errors.forEach(error => { errorsByType[error.context] = (errorsByType[error.context] || 0) + 1; }); // Timeline data for charts const timelineData = generateTimelineData(timeline, startTime, endTime); const html = ` CTFG Performance Test Report

🚀 CTFG Performance Test Report

Generated on ${new Date().toLocaleString()}
${stats.totalPlayers}
Total Players
${stats.duration}s
Test Duration
${stats.loginSuccessRate}%
Login Success Rate
${stats.wsSuccessRate}%
WebSocket Success Rate
${stats.throughput.loginsPerSecond}
Logins/Second
${stats.throughput.flagsPerSecond}
Flags/Second
📊 Response Times Over Time
⚡ Throughput Over Time
📈 Response Time Statistics
Endpoint Mean Median 95th %ile 99th %ile Min Max Std Dev
Login ${stats.percentiles.login.mean}ms ${stats.percentiles.login.median}ms ${stats.percentiles.login.p95}ms ${stats.percentiles.login.p99}ms ${stats.percentiles.login.min}ms ${stats.percentiles.login.max}ms ${stats.percentiles.login.stdDev}ms
Flag Submit ${stats.percentiles.submit.mean}ms ${stats.percentiles.submit.median}ms ${stats.percentiles.submit.p95}ms ${stats.percentiles.submit.p99}ms ${stats.percentiles.submit.min}ms ${stats.percentiles.submit.max}ms ${stats.percentiles.submit.stdDev}ms
Challenges API ${stats.averageResponseTimes.challenges}ms Individual timing data not collected
Static Files ${stats.averageResponseTimes.staticFiles}ms Individual timing data not collected
${stats.staticFileBreakdown ? `
📁 Static File Performance Breakdown
${Object.entries(stats.staticFileBreakdown).map(([fileName, stats]) => { const requestCount = fileName === 'index.html' ? metricsData.staticFileBreakdown['index.html'].length : fileName === 'CSS' ? metricsData.staticFileBreakdown['CSS'].length : fileName === 'banner image' ? metricsData.staticFileBreakdown['banner image'].length : fileName === 'app.js' ? metricsData.staticFileBreakdown['app.js'].length : 0; return ``; }).join('')}
File Mean Median 95th %ile 99th %ile Min Max Std Dev Requests
${fileName} ${stats.mean}ms ${stats.median}ms ${stats.p95}ms ${stats.p99}ms ${stats.min}ms ${stats.max}ms ${stats.stdDev}ms ${requestCount}

💡 Tip: Look for files with high mean/median times or high standard deviation (indicating inconsistent performance). Large images or JavaScript files often take the longest to serve.

` : ''}
👤 User Experience Metrics
${Math.round(stats.userExperience.timeToFirstLogin.median / 1000)}s
Time to First Login (Median)
${Math.round(stats.userExperience.timeToFirstFlag.median / 1000)}s
Login to First Flag (Median)
${stats.userExperience.successRate}%
Login Success Rate
${stats.userExperience.completionRate}%
Challenge Completion
User Journey Stage Count Mean Median 95th %ile Min Max Interpretation
🚪 Time to First Login ${stats.userExperience.timeToFirstLogin.count} ${Math.round(stats.userExperience.timeToFirstLogin.mean / 1000)}s ${Math.round(stats.userExperience.timeToFirstLogin.median / 1000)}s ${Math.round(stats.userExperience.timeToFirstLogin.p95 / 1000)}s ${Math.round(stats.userExperience.timeToFirstLogin.min / 1000)}s ${Math.round(stats.userExperience.timeToFirstLogin.max / 1000)}s From player start to successful login
🎯 Login to First Flag ${stats.userExperience.timeToFirstFlag.count} ${Math.round(stats.userExperience.timeToFirstFlag.mean / 1000)}s ${Math.round(stats.userExperience.timeToFirstFlag.median / 1000)}s ${Math.round(stats.userExperience.timeToFirstFlag.p95 / 1000)}s ${Math.round(stats.userExperience.timeToFirstFlag.min / 1000)}s ${Math.round(stats.userExperience.timeToFirstFlag.max / 1000)}s Time from successful login to first solve
🏃 Complete User Journey ${stats.userExperience.completeUserJourney.count} ${Math.round(stats.userExperience.completeUserJourney.mean / 1000)}s ${Math.round(stats.userExperience.completeUserJourney.median / 1000)}s ${Math.round(stats.userExperience.completeUserJourney.p95 / 1000)}s ${Math.round(stats.userExperience.completeUserJourney.min / 1000)}s ${Math.round(stats.userExperience.completeUserJourney.max / 1000)}s Total time from player start to first solve
⏱️ Session Duration ${stats.userExperience.sessionDurations.count} ${Math.round(stats.userExperience.sessionDurations.mean / 1000)}s ${Math.round(stats.userExperience.sessionDurations.median / 1000)}s ${Math.round(stats.userExperience.sessionDurations.p95 / 1000)}s ${Math.round(stats.userExperience.sessionDurations.min / 1000)}s ${Math.round(stats.userExperience.sessionDurations.max / 1000)}s How long players stay engaged

🎯 Test Performance Insights

  • Median Journey: Typical player takes ${Math.round(stats.userExperience.timeToFirstLogin.median / 1000)}s to login and ${Math.round(stats.userExperience.timeToFirstFlag.median / 1000)}s to reach first flag
  • Login Success: ${stats.userExperience.successRate}% of players successfully logged in (${stats.userExperience.failedLogins} failures)
  • Test Completion: ${stats.userExperience.completionRate}% of players completed challenge solving
  • ${stats.userExperience.successRate < 95 ? '
  • ⚠️ Login Failures: Server may be struggling with authentication load
  • ' : ''} ${stats.userExperience.timeToFirstLogin.p95 > 60000 ? '
  • ⚠️ Slow Logins: 95% percentile login time exceeds 60 seconds - check server performance
  • ' : ''} ${stats.userExperience.timeToFirstFlag.p95 > 120000 ? '
  • ⚠️ Slow Challenge Loading: Some players taking >2 minutes to reach first challenge
  • ' : ''} ${stats.userExperience.sessionDurations.median < 30000 ? '
  • ⚠️ Short Sessions: Median session under 30s - possible early failures
  • ' : ''}
${totals.errors.length > 0 ? `
⚠️ Errors (${totals.errors.length} total)
Error Distribution
${Object.entries(errorsByType).map(([type, count]) => `` ).join('')}
Error Type Count Percentage
${type} ${count} ${(count / totals.errors.length * 100).toFixed(1)}%
Recent Errors
    ${totals.errors.slice(0, 20).map(error => `
  • [${new Date(error.timestamp).toLocaleTimeString()}] ${error.context}: ${error.error} ${error.playerId ? ` (Player ${error.playerId})` : ''}
  • ` ).join('')}
${totals.errors.length > 20 ? `

... and ${totals.errors.length - 20} more errors

` : ''}
` : '
No errors detected!
'}
`; fs.writeFileSync(outputFile, html); console.log(`📊 Performance report generated: ${outputFile}`); } function calculateUserExperienceMetrics(players, testStartTime) { const uxMetrics = { timeToFirstLogin: [], // Time from player start to successful login timeToFirstFlag: [], // Time from login to first flag submission completeUserJourney: [], // Total time from player start to first solve challengeSolveTimes: [], // Time to solve each challenge (for all challenges) sessionDurations: [], // Total time each player was active failedLogins: 0, // Players who failed to login incompleteJourneys: 0 // Players who logged in but didn't complete all challenges }; players.forEach(player => { if (!player.metrics) return; const playerStart = player.metrics.startTime; const loginTime = player.metrics.loginTime; const firstChallengeTime = player.metrics.firstChallengeTime; const challengesSolved = player.metrics.challengesSolved || 0; // Time to first login (from when THIS player started, not global test start) if (loginTime && playerStart) { const ttfl = loginTime - playerStart; if (ttfl > 0) uxMetrics.timeToFirstLogin.push(ttfl); } // User journey timings (from their own start time) if (loginTime && firstChallengeTime) { const timeToFlag = firstChallengeTime - loginTime; const completeJourney = firstChallengeTime - playerStart; uxMetrics.timeToFirstFlag.push(timeToFlag); uxMetrics.completeUserJourney.push(completeJourney); } // Failed logins (never got a login time) if (!loginTime && playerStart) { uxMetrics.failedLogins++; } // Incomplete journeys (logged in but didn't solve challenges) if (loginTime && challengesSolved === 0) { uxMetrics.incompleteJourneys++; } // Session duration if (playerStart) { const sessionEnd = firstChallengeTime || loginTime || Date.now(); const sessionDuration = sessionEnd - playerStart; uxMetrics.sessionDurations.push(sessionDuration); } }); // Calculate summary stats return { timeToFirstLogin: { count: uxMetrics.timeToFirstLogin.length, ...calculatePercentiles(uxMetrics.timeToFirstLogin) }, timeToFirstFlag: { count: uxMetrics.timeToFirstFlag.length, ...calculatePercentiles(uxMetrics.timeToFirstFlag) }, completeUserJourney: { count: uxMetrics.completeUserJourney.length, ...calculatePercentiles(uxMetrics.completeUserJourney) }, sessionDurations: { count: uxMetrics.sessionDurations.length, ...calculatePercentiles(uxMetrics.sessionDurations) }, failedLogins: uxMetrics.failedLogins, incompleteJourneys: uxMetrics.incompleteJourneys, totalPlayers: players.length, successRate: players.length > 0 ? ((players.length - uxMetrics.failedLogins) / players.length * 100).toFixed(1) : '0.0', completionRate: players.length > 0 ? ((players.length - uxMetrics.incompleteJourneys) / players.length * 100).toFixed(1) : '0.0' }; } function calculateAverage(arr) { if (arr.length === 0) return 0; return Math.round(arr.reduce((a, b) => a + b, 0) / arr.length); } function calculatePercentiles(arr) { if (arr.length === 0) return { mean: 0, median: 0, p50: 0, p95: 0, p99: 0, max: 0, min: 0, stdDev: 0 }; const sorted = arr.slice().sort((a, b) => a - b); const mean = arr.reduce((sum, val) => sum + val, 0) / arr.length; // Standard deviation const variance = arr.reduce((sum, val) => sum + Math.pow(val - mean, 2), 0) / arr.length; const stdDev = Math.sqrt(variance); return { mean: Math.round(mean), median: Math.round(sorted[Math.floor(sorted.length * 0.5)]), p50: Math.round(sorted[Math.floor(sorted.length * 0.5)]), p95: Math.round(sorted[Math.floor(sorted.length * 0.95)]), p99: Math.round(sorted[Math.floor(sorted.length * 0.99)]), max: Math.round(sorted[sorted.length - 1]), min: Math.round(sorted[0]), stdDev: Math.round(stdDev) }; } function generateTimelineData(timeline, startTime, endTime) { const duration = endTime - startTime; const buckets = 20; // 20 time buckets for charts const bucketSize = duration / buckets; const labels = []; const loginTimes = []; const submitTimes = []; const activePlayers = []; const requestsPerSecond = []; for (let i = 0; i < buckets; i++) { const bucketStart = startTime + (i * bucketSize); const bucketEnd = bucketStart + bucketSize; // Create time label const timeLabel = new Date(bucketStart).toLocaleTimeString(); labels.push(timeLabel); // Filter events in this bucket const bucketEvents = timeline.filter(event => event.timestamp >= bucketStart && event.timestamp < bucketEnd ); // Calculate metrics for this bucket const loginEvents = bucketEvents.filter(e => e.category === 'login_attempt'); const submitEvents = bucketEvents.filter(e => e.category === 'submit_flag'); loginTimes.push(loginEvents.length > 0 ? Math.round(loginEvents.reduce((sum, e) => sum + e.value, 0) / loginEvents.length) : 0); submitTimes.push(submitEvents.length > 0 ? Math.round(submitEvents.reduce((sum, e) => sum + e.value, 0) / submitEvents.length) : 0); // Estimate active players (unique player IDs in bucket) const uniquePlayers = new Set(bucketEvents.map(e => e.playerId)).size; activePlayers.push(uniquePlayers); // Requests per second in this bucket const requestCount = bucketEvents.length; const rps = Math.round(requestCount / (bucketSize / 1000)); requestsPerSecond.push(rps); } return { labels, loginTimes, submitTimes, activePlayers, requestsPerSecond }; } module.exports = { generatePerformanceReport }; // If run directly, expect metrics file as argument if (require.main === module) { const metricsFile = process.argv[2]; if (!metricsFile) { console.error('Usage: node generate-performance-report.js '); process.exit(1); } const metrics = JSON.parse(fs.readFileSync(metricsFile, 'utf8')); generatePerformanceReport(metrics); }