Bundle analysis and monitoring
This commit is contained in:
@@ -0,0 +1,253 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Bundle Analysis Script
|
||||
* Analyzes webpack bundles and provides detailed performance insights
|
||||
*/
|
||||
|
||||
const { execSync } = require("child_process");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const BUNDLE_ANALYSIS_DIR = path.join(__dirname, "..", ".next", "analyze");
|
||||
const PERFORMANCE_BUDGETS = require("../performance-budgets.json");
|
||||
|
||||
class BundleAnalyzer {
|
||||
constructor() {
|
||||
this.results = {
|
||||
timestamp: new Date().toISOString(),
|
||||
bundles: {},
|
||||
recommendations: [],
|
||||
budgetViolations: [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Run bundle analysis using build output
|
||||
*/
|
||||
async analyzeBundles() {
|
||||
console.log("🔍 Starting bundle analysis...");
|
||||
|
||||
try {
|
||||
// Ensure analyze directory exists
|
||||
if (!fs.existsSync(BUNDLE_ANALYSIS_DIR)) {
|
||||
fs.mkdirSync(BUNDLE_ANALYSIS_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
// Build the project first
|
||||
console.log("🏗️ Building project...");
|
||||
execSync("npm run build", { stdio: "inherit" });
|
||||
|
||||
// Parse bundle stats from build output
|
||||
await this.parseBundleStats();
|
||||
|
||||
// Check performance budgets
|
||||
this.checkPerformanceBudgets();
|
||||
|
||||
// Generate recommendations
|
||||
this.generateRecommendations();
|
||||
|
||||
// Save results
|
||||
this.saveResults();
|
||||
|
||||
console.log("✅ Bundle analysis complete!");
|
||||
console.log(`📁 Results saved to: ${BUNDLE_ANALYSIS_DIR}`);
|
||||
} catch (error) {
|
||||
console.error("❌ Bundle analysis failed:", error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse bundle statistics from build output
|
||||
*/
|
||||
async parseBundleStats() {
|
||||
const staticPath = path.join(__dirname, "..", ".next", "static");
|
||||
const chunksPath = path.join(staticPath, "chunks");
|
||||
|
||||
// Analyze static assets
|
||||
if (fs.existsSync(staticPath)) {
|
||||
this.analyzeDirectory(staticPath, "static");
|
||||
}
|
||||
|
||||
// Analyze chunks
|
||||
if (fs.existsSync(chunksPath)) {
|
||||
this.analyzeDirectory(chunksPath, "chunks");
|
||||
}
|
||||
|
||||
// Analyze pages
|
||||
const pagesPath = path.join(__dirname, "..", ".next", "server", "pages");
|
||||
if (fs.existsSync(pagesPath)) {
|
||||
this.analyzeDirectory(pagesPath, "pages");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze directory for bundle sizes
|
||||
*/
|
||||
analyzeDirectory(dirPath, type) {
|
||||
const files = fs.readdirSync(dirPath);
|
||||
|
||||
files.forEach((file) => {
|
||||
const filePath = path.join(dirPath, file);
|
||||
const stats = fs.statSync(filePath);
|
||||
|
||||
if (stats.isFile() && (file.endsWith(".js") || file.endsWith(".css"))) {
|
||||
const key = `${type}/${file}`;
|
||||
this.results.bundles[key] = {
|
||||
size: stats.size,
|
||||
sizeKB: Math.round(stats.size / 1024),
|
||||
lastModified: stats.mtime,
|
||||
type: file.endsWith(".css") ? "css" : "js",
|
||||
};
|
||||
} else if (stats.isDirectory()) {
|
||||
this.analyzeDirectory(filePath, `${type}/${file}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check against performance budgets
|
||||
*/
|
||||
checkPerformanceBudgets() {
|
||||
const budgets = PERFORMANCE_BUDGETS.budgets || [];
|
||||
|
||||
Object.entries(this.results.bundles).forEach(([filename, bundle]) => {
|
||||
const budget = budgets.find(
|
||||
(b) => filename.includes(b.name) || b.name === "all"
|
||||
);
|
||||
|
||||
if (budget) {
|
||||
if (bundle.sizeKB > budget.maxSizeKB) {
|
||||
this.results.budgetViolations.push({
|
||||
file: filename,
|
||||
currentSize: bundle.sizeKB,
|
||||
maxSize: budget.maxSizeKB,
|
||||
overage: bundle.sizeKB - budget.maxSizeKB,
|
||||
severity:
|
||||
bundle.sizeKB > budget.maxSizeKB * 1.2 ? "high" : "medium",
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Default budget check for large files
|
||||
if (bundle.sizeKB > 500) {
|
||||
this.results.budgetViolations.push({
|
||||
file: filename,
|
||||
currentSize: bundle.sizeKB,
|
||||
maxSize: 500,
|
||||
overage: bundle.sizeKB - 500,
|
||||
severity: bundle.sizeKB > 600 ? "high" : "medium",
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate optimization recommendations
|
||||
*/
|
||||
generateRecommendations() {
|
||||
const recommendations = [];
|
||||
|
||||
// Check for large bundles
|
||||
Object.entries(this.results.bundles).forEach(([filename, bundle]) => {
|
||||
if (bundle.sizeKB > 500) {
|
||||
recommendations.push({
|
||||
type: "large-bundle",
|
||||
file: filename,
|
||||
size: bundle.sizeKB,
|
||||
suggestion:
|
||||
"Consider code splitting or dynamic imports for this bundle",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Check for budget violations
|
||||
if (this.results.budgetViolations.length > 0) {
|
||||
recommendations.push({
|
||||
type: "budget-violation",
|
||||
count: this.results.budgetViolations.length,
|
||||
suggestion:
|
||||
"Review and optimize bundles that exceed performance budgets",
|
||||
});
|
||||
}
|
||||
|
||||
// General recommendations
|
||||
const totalSize = Object.values(this.results.bundles).reduce(
|
||||
(sum, bundle) => sum + bundle.sizeKB,
|
||||
0
|
||||
);
|
||||
|
||||
if (totalSize > 2000) {
|
||||
recommendations.push({
|
||||
type: "total-size",
|
||||
size: totalSize,
|
||||
suggestion: "Consider implementing more aggressive code splitting",
|
||||
});
|
||||
}
|
||||
|
||||
this.results.recommendations = recommendations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save analysis results
|
||||
*/
|
||||
saveResults() {
|
||||
// Ensure directory exists
|
||||
if (!fs.existsSync(BUNDLE_ANALYSIS_DIR)) {
|
||||
fs.mkdirSync(BUNDLE_ANALYSIS_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
const resultsPath = path.join(BUNDLE_ANALYSIS_DIR, "bundle-analysis.json");
|
||||
fs.writeFileSync(resultsPath, JSON.stringify(this.results, null, 2));
|
||||
|
||||
// Generate markdown report
|
||||
this.generateMarkdownReport();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate markdown report
|
||||
*/
|
||||
generateMarkdownReport() {
|
||||
const reportPath = path.join(BUNDLE_ANALYSIS_DIR, "bundle-report.md");
|
||||
|
||||
let report = `# Bundle Analysis Report\n\n`;
|
||||
report += `**Generated:** ${this.results.timestamp}\n\n`;
|
||||
|
||||
// Bundle sizes
|
||||
report += `## Bundle Sizes\n\n`;
|
||||
report += `| File | Size (KB) | Status |\n`;
|
||||
report += `|------|-----------|--------|\n`;
|
||||
|
||||
Object.entries(this.results.bundles).forEach(([filename, bundle]) => {
|
||||
const status = bundle.sizeKB > 500 ? "⚠️ Large" : "✅ Good";
|
||||
report += `| ${filename} | ${bundle.sizeKB} | ${status} |\n`;
|
||||
});
|
||||
|
||||
// Budget violations
|
||||
if (this.results.budgetViolations.length > 0) {
|
||||
report += `\n## Budget Violations\n\n`;
|
||||
this.results.budgetViolations.forEach((violation) => {
|
||||
report += `- **${violation.file}**: ${violation.currentSize}KB (exceeds ${violation.maxSize}KB by ${violation.overage}KB)\n`;
|
||||
});
|
||||
}
|
||||
|
||||
// Recommendations
|
||||
if (this.results.recommendations.length > 0) {
|
||||
report += `\n## Recommendations\n\n`;
|
||||
this.results.recommendations.forEach((rec) => {
|
||||
report += `- ${rec.suggestion}\n`;
|
||||
});
|
||||
}
|
||||
|
||||
fs.writeFileSync(reportPath, report);
|
||||
}
|
||||
}
|
||||
|
||||
// Run analysis if called directly
|
||||
if (require.main === module) {
|
||||
const analyzer = new BundleAnalyzer();
|
||||
analyzer.analyzeBundles().catch(console.error);
|
||||
}
|
||||
|
||||
module.exports = BundleAnalyzer;
|
||||
+265
-354
@@ -2,386 +2,297 @@
|
||||
|
||||
/**
|
||||
* Performance Monitoring Script
|
||||
*
|
||||
* This script provides comprehensive performance monitoring capabilities
|
||||
* for the Community Rule application.
|
||||
* Monitors Core Web Vitals and performance metrics
|
||||
*/
|
||||
|
||||
const { spawn } = require("child_process");
|
||||
const { execSync } = require("child_process");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
// Performance budgets
|
||||
const PERFORMANCE_BUDGETS = {
|
||||
page_load_time: 3000,
|
||||
first_contentful_paint: 2000,
|
||||
largest_contentful_paint: 2500,
|
||||
first_input_delay: 100,
|
||||
dns_lookup: 100,
|
||||
tcp_connection: 200,
|
||||
ttfb: 600,
|
||||
dom_content_loaded: 1500,
|
||||
full_load: 3000,
|
||||
component_render_time: 500,
|
||||
interaction_time: 100,
|
||||
scroll_performance: 50,
|
||||
network_request_duration: 1000,
|
||||
memory_usage_mb: 50,
|
||||
};
|
||||
const PERFORMANCE_BUDGETS = require("../performance-budgets.json");
|
||||
const MONITORING_DIR = path.join(__dirname, "..", ".next", "monitoring");
|
||||
|
||||
// Baseline metrics for regression detection
|
||||
const BASELINE_METRICS = {
|
||||
page_load_time: 2000,
|
||||
first_contentful_paint: 1500,
|
||||
largest_contentful_paint: 2000,
|
||||
first_input_delay: 50,
|
||||
dns_lookup: 50,
|
||||
tcp_connection: 100,
|
||||
ttfb: 400,
|
||||
dom_content_loaded: 1000,
|
||||
full_load: 2000,
|
||||
component_render_time: 300,
|
||||
interaction_time: 50,
|
||||
scroll_performance: 30,
|
||||
network_request_duration: 500,
|
||||
memory_usage_mb: 30,
|
||||
};
|
||||
|
||||
class PerformanceMonitorScript {
|
||||
class PerformanceMonitor {
|
||||
constructor() {
|
||||
this.metrics = new Map();
|
||||
this.regressions = [];
|
||||
this.warnings = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Run Lighthouse CI performance tests
|
||||
*/
|
||||
async runLighthouseCI() {
|
||||
console.log("🚀 Running Lighthouse CI performance tests...");
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const lhci = spawn("npx", ["lhci", "autorun"], {
|
||||
stdio: "pipe",
|
||||
shell: true,
|
||||
});
|
||||
|
||||
let output = "";
|
||||
let errorOutput = "";
|
||||
|
||||
lhci.stdout.on("data", (data) => {
|
||||
output += data.toString();
|
||||
console.log(data.toString());
|
||||
});
|
||||
|
||||
lhci.stderr.on("data", (data) => {
|
||||
errorOutput += data.toString();
|
||||
console.error(data.toString());
|
||||
});
|
||||
|
||||
lhci.on("close", (code) => {
|
||||
if (code === 0) {
|
||||
console.log("✅ Lighthouse CI tests completed successfully");
|
||||
this.analyzeLighthouseResults(output);
|
||||
resolve(output);
|
||||
} else {
|
||||
console.error("❌ Lighthouse CI tests failed");
|
||||
reject(
|
||||
new Error(`Lighthouse CI failed with code ${code}: ${errorOutput}`),
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Run Playwright performance tests
|
||||
*/
|
||||
async runPlaywrightPerformanceTests() {
|
||||
console.log("🎭 Running Playwright performance tests...");
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const playwright = spawn(
|
||||
"npx",
|
||||
[
|
||||
"playwright",
|
||||
"test",
|
||||
"tests/e2e/performance.spec.ts",
|
||||
"--reporter=json",
|
||||
],
|
||||
{
|
||||
stdio: "pipe",
|
||||
shell: true,
|
||||
},
|
||||
);
|
||||
|
||||
let output = "";
|
||||
let errorOutput = "";
|
||||
|
||||
playwright.stdout.on("data", (data) => {
|
||||
output += data.toString();
|
||||
});
|
||||
|
||||
playwright.stderr.on("data", (data) => {
|
||||
errorOutput += data.toString();
|
||||
});
|
||||
|
||||
playwright.on("close", (code) => {
|
||||
if (code === 0) {
|
||||
console.log("✅ Playwright performance tests completed successfully");
|
||||
this.analyzePlaywrightResults(output);
|
||||
resolve(output);
|
||||
} else {
|
||||
console.error("❌ Playwright performance tests failed");
|
||||
reject(
|
||||
new Error(
|
||||
`Playwright tests failed with code ${code}: ${errorOutput}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze Lighthouse CI results
|
||||
*/
|
||||
analyzeLighthouseResults(output) {
|
||||
console.log("📊 Analyzing Lighthouse CI results...");
|
||||
|
||||
// Parse Lighthouse results
|
||||
const lines = output.split("\n");
|
||||
let currentMetric = null;
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.includes("Performance")) {
|
||||
const scoreMatch = line.match(/(\d+)/);
|
||||
if (scoreMatch) {
|
||||
const score = parseInt(scoreMatch[1]);
|
||||
this.recordMetric("lighthouse_performance_score", score);
|
||||
|
||||
if (score < 90) {
|
||||
this.warnings.push(
|
||||
`Performance score below threshold: ${score}/100`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (line.includes("First Contentful Paint")) {
|
||||
const timeMatch = line.match(/(\d+(?:\.\d+)?)\s*ms/);
|
||||
if (timeMatch) {
|
||||
const time = parseFloat(timeMatch[1]);
|
||||
this.recordMetric("first_contentful_paint", time);
|
||||
|
||||
if (time > PERFORMANCE_BUDGETS.first_contentful_paint) {
|
||||
this.warnings.push(
|
||||
`First Contentful Paint exceeded budget: ${time}ms`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (line.includes("Largest Contentful Paint")) {
|
||||
const timeMatch = line.match(/(\d+(?:\.\d+)?)\s*ms/);
|
||||
if (timeMatch) {
|
||||
const time = parseFloat(timeMatch[1]);
|
||||
this.recordMetric("largest_contentful_paint", time);
|
||||
|
||||
if (time > PERFORMANCE_BUDGETS.largest_contentful_paint) {
|
||||
this.warnings.push(
|
||||
`Largest Contentful Paint exceeded budget: ${time}ms`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (line.includes("Total Blocking Time")) {
|
||||
const timeMatch = line.match(/(\d+(?:\.\d+)?)\s*ms/);
|
||||
if (timeMatch) {
|
||||
const time = parseFloat(timeMatch[1]);
|
||||
this.recordMetric("total_blocking_time", time);
|
||||
|
||||
if (time > 300) {
|
||||
this.warnings.push(
|
||||
`Total Blocking Time exceeded budget: ${time}ms`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (line.includes("Cumulative Layout Shift")) {
|
||||
const shiftMatch = line.match(/(\d+(?:\.\d+)?)/);
|
||||
if (shiftMatch) {
|
||||
const shift = parseFloat(shiftMatch[1]);
|
||||
this.recordMetric("cumulative_layout_shift", shift);
|
||||
|
||||
if (shift > 0.1) {
|
||||
this.warnings.push(
|
||||
`Cumulative Layout Shift exceeded budget: ${shift}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze Playwright test results
|
||||
*/
|
||||
analyzePlaywrightResults(output) {
|
||||
console.log("📊 Analyzing Playwright test results...");
|
||||
|
||||
try {
|
||||
const results = JSON.parse(output);
|
||||
|
||||
for (const result of results) {
|
||||
if (result.status === "failed") {
|
||||
this.warnings.push(`Test failed: ${result.title}`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("Could not parse Playwright results as JSON");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a performance metric
|
||||
*/
|
||||
recordMetric(name, value) {
|
||||
if (!this.metrics.has(name)) {
|
||||
this.metrics.set(name, []);
|
||||
}
|
||||
this.metrics.get(name).push({
|
||||
value,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
// Check against baseline for regression detection
|
||||
const baseline = BASELINE_METRICS[name];
|
||||
if (baseline) {
|
||||
const regressionThreshold = baseline * 1.2; // 20% regression threshold
|
||||
if (value > regressionThreshold) {
|
||||
this.regressions.push({
|
||||
metric: name,
|
||||
current: value,
|
||||
baseline,
|
||||
regression: (((value - baseline) / baseline) * 100).toFixed(1) + "%",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate performance report
|
||||
*/
|
||||
generateReport() {
|
||||
console.log("\n📈 Performance Monitoring Report");
|
||||
console.log("================================\n");
|
||||
|
||||
// Summary
|
||||
console.log("📊 Summary:");
|
||||
console.log(`- Total metrics recorded: ${this.metrics.size}`);
|
||||
console.log(
|
||||
`- Performance regressions detected: ${this.regressions.length}`,
|
||||
);
|
||||
console.log(`- Warnings: ${this.warnings.length}\n`);
|
||||
|
||||
// Performance regressions
|
||||
if (this.regressions.length > 0) {
|
||||
console.log("🚨 Performance Regressions:");
|
||||
for (const regression of this.regressions) {
|
||||
console.log(
|
||||
` - ${regression.metric}: ${regression.current} (baseline: ${regression.baseline}, regression: ${regression.regression})`,
|
||||
);
|
||||
}
|
||||
console.log("");
|
||||
}
|
||||
|
||||
// Warnings
|
||||
if (this.warnings.length > 0) {
|
||||
console.log("⚠️ Warnings:");
|
||||
for (const warning of this.warnings) {
|
||||
console.log(` - ${warning}`);
|
||||
}
|
||||
console.log("");
|
||||
}
|
||||
|
||||
// Metrics summary
|
||||
console.log("📋 Metrics Summary:");
|
||||
for (const [name, values] of this.metrics) {
|
||||
const latest = values[values.length - 1];
|
||||
const average =
|
||||
values.reduce((sum, v) => sum + v.value, 0) / values.length;
|
||||
const budget = PERFORMANCE_BUDGETS[name];
|
||||
|
||||
console.log(` - ${name}:`);
|
||||
console.log(` Latest: ${latest.value}`);
|
||||
console.log(` Average: ${average.toFixed(2)}`);
|
||||
if (budget) {
|
||||
const status = latest.value <= budget ? "✅" : "❌";
|
||||
console.log(` Budget: ${budget} ${status}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Save report to file
|
||||
const report = {
|
||||
this.metrics = {
|
||||
timestamp: new Date().toISOString(),
|
||||
summary: {
|
||||
totalMetrics: this.metrics.size,
|
||||
regressions: this.regressions.length,
|
||||
warnings: this.warnings.length,
|
||||
},
|
||||
regressions: this.regressions,
|
||||
warnings: this.warnings,
|
||||
metrics: Object.fromEntries(this.metrics),
|
||||
coreWebVitals: {},
|
||||
bundleMetrics: {},
|
||||
recommendations: [],
|
||||
};
|
||||
|
||||
const reportPath = path.join(__dirname, "../performance-report.json");
|
||||
fs.writeFileSync(reportPath, JSON.stringify(report, null, 2));
|
||||
console.log(`\n📄 Report saved to: ${reportPath}`);
|
||||
|
||||
return report;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run all performance monitoring
|
||||
* Run comprehensive performance monitoring
|
||||
*/
|
||||
async run() {
|
||||
console.log("🔍 Starting Performance Monitoring...\n");
|
||||
async monitorPerformance() {
|
||||
console.log("📊 Starting performance monitoring...");
|
||||
|
||||
try {
|
||||
// Run Lighthouse CI tests
|
||||
// Ensure monitoring directory exists
|
||||
if (!fs.existsSync(MONITORING_DIR)) {
|
||||
fs.mkdirSync(MONITORING_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
// Run Lighthouse CI for Core Web Vitals
|
||||
await this.runLighthouseCI();
|
||||
|
||||
// Run Playwright performance tests
|
||||
await this.runPlaywrightPerformanceTests();
|
||||
// Analyze bundle performance
|
||||
await this.analyzeBundlePerformance();
|
||||
|
||||
// Generate and display report
|
||||
const report = this.generateReport();
|
||||
// Check performance budgets
|
||||
this.checkPerformanceBudgets();
|
||||
|
||||
// Exit with appropriate code
|
||||
if (this.regressions.length > 0) {
|
||||
console.log("❌ Performance regressions detected!");
|
||||
process.exit(1);
|
||||
} else if (this.warnings.length > 0) {
|
||||
console.log("⚠️ Performance warnings detected.");
|
||||
process.exit(0);
|
||||
} else {
|
||||
console.log("✅ All performance checks passed!");
|
||||
process.exit(0);
|
||||
}
|
||||
// Generate performance report
|
||||
this.generatePerformanceReport();
|
||||
|
||||
console.log("✅ Performance monitoring complete!");
|
||||
console.log(`📁 Results saved to: ${MONITORING_DIR}`);
|
||||
} catch (error) {
|
||||
console.error("❌ Performance monitoring failed:", error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run Lighthouse CI for Core Web Vitals
|
||||
*/
|
||||
async runLighthouseCI() {
|
||||
console.log("🔍 Running Lighthouse CI...");
|
||||
|
||||
try {
|
||||
// Check if server is running
|
||||
const { execSync } = require("child_process");
|
||||
try {
|
||||
execSync("curl -s http://localhost:3000 > /dev/null", {
|
||||
stdio: "pipe",
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
"⚠️ Development server not running, skipping Lighthouse CI..."
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Run Lighthouse CI with performance focus
|
||||
execSync("npx lhci autorun --collect.url=http://localhost:3000", {
|
||||
stdio: "inherit",
|
||||
cwd: path.join(__dirname, ".."),
|
||||
});
|
||||
|
||||
// Parse Lighthouse results
|
||||
await this.parseLighthouseResults();
|
||||
} catch (error) {
|
||||
console.warn("⚠️ Lighthouse CI failed, continuing with other metrics...");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse Lighthouse CI results
|
||||
*/
|
||||
async parseLighthouseResults() {
|
||||
const lhciResultsPath = path.join(__dirname, "..", ".lighthouseci");
|
||||
|
||||
if (fs.existsSync(lhciResultsPath)) {
|
||||
const files = fs.readdirSync(lhciResultsPath);
|
||||
const resultFile = files.find((f) => f.endsWith(".json"));
|
||||
|
||||
if (resultFile) {
|
||||
const results = JSON.parse(
|
||||
fs.readFileSync(path.join(lhciResultsPath, resultFile), "utf8")
|
||||
);
|
||||
|
||||
if (results.lhr && results.lhr.audits) {
|
||||
this.metrics.coreWebVitals = {
|
||||
lcp: this.getAuditScore(
|
||||
results.lhr.audits,
|
||||
"largest-contentful-paint"
|
||||
),
|
||||
fid: this.getAuditScore(results.lhr.audits, "max-potential-fid"),
|
||||
cls: this.getAuditScore(
|
||||
results.lhr.audits,
|
||||
"cumulative-layout-shift"
|
||||
),
|
||||
fcp: this.getAuditScore(
|
||||
results.lhr.audits,
|
||||
"first-contentful-paint"
|
||||
),
|
||||
tti: this.getAuditScore(results.lhr.audits, "interactive"),
|
||||
performance: results.lhr.categories.performance?.score * 100 || 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get audit score from Lighthouse results
|
||||
*/
|
||||
getAuditScore(audits, auditId) {
|
||||
const audit = audits[auditId];
|
||||
if (!audit) return null;
|
||||
|
||||
return {
|
||||
score: audit.score * 100,
|
||||
value: audit.numericValue,
|
||||
displayValue: audit.displayValue,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze bundle performance
|
||||
*/
|
||||
async analyzeBundlePerformance() {
|
||||
console.log("📦 Analyzing bundle performance...");
|
||||
|
||||
const bundleStatsPath = path.join(
|
||||
__dirname,
|
||||
"..",
|
||||
".next",
|
||||
"static",
|
||||
"chunks"
|
||||
);
|
||||
|
||||
if (fs.existsSync(bundleStatsPath)) {
|
||||
const files = fs.readdirSync(bundleStatsPath);
|
||||
let totalSize = 0;
|
||||
let jsFiles = 0;
|
||||
|
||||
files.forEach((file) => {
|
||||
if (file.endsWith(".js")) {
|
||||
const filePath = path.join(bundleStatsPath, file);
|
||||
const stats = fs.statSync(filePath);
|
||||
totalSize += stats.size;
|
||||
jsFiles++;
|
||||
}
|
||||
});
|
||||
|
||||
this.metrics.bundleMetrics = {
|
||||
totalSizeKB: Math.round(totalSize / 1024),
|
||||
totalSizeMB: Math.round((totalSize / (1024 * 1024)) * 100) / 100,
|
||||
fileCount: jsFiles,
|
||||
averageSizeKB: Math.round(totalSize / jsFiles / 1024),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check performance budgets
|
||||
*/
|
||||
checkPerformanceBudgets() {
|
||||
const budgets = PERFORMANCE_BUDGETS.budgets;
|
||||
const violations = [];
|
||||
|
||||
// Check Core Web Vitals
|
||||
if (this.metrics.coreWebVitals.lcp) {
|
||||
const lcpValue = this.metrics.coreWebVitals.lcp.value;
|
||||
const lcpBudget = budgets.find((b) => b.name === "lcp")?.maxValue;
|
||||
|
||||
if (lcpBudget && lcpValue > lcpBudget) {
|
||||
violations.push({
|
||||
metric: "LCP",
|
||||
current: lcpValue,
|
||||
budget: lcpBudget,
|
||||
severity: lcpValue > lcpBudget * 1.5 ? "high" : "medium",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Check bundle size
|
||||
if (this.metrics.bundleMetrics.totalSizeKB > 2000) {
|
||||
violations.push({
|
||||
metric: "Bundle Size",
|
||||
current: this.metrics.bundleMetrics.totalSizeKB,
|
||||
budget: 2000,
|
||||
severity: "medium",
|
||||
});
|
||||
}
|
||||
|
||||
this.metrics.budgetViolations = violations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate performance report
|
||||
*/
|
||||
generatePerformanceReport() {
|
||||
const reportPath = path.join(MONITORING_DIR, "performance-report.json");
|
||||
fs.writeFileSync(reportPath, JSON.stringify(this.metrics, null, 2));
|
||||
|
||||
// Generate markdown report
|
||||
this.generateMarkdownReport();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate markdown performance report
|
||||
*/
|
||||
generateMarkdownReport() {
|
||||
const reportPath = path.join(MONITORING_DIR, "performance-report.md");
|
||||
|
||||
let report = `# Performance Monitoring Report\n\n`;
|
||||
report += `**Generated:** ${this.metrics.timestamp}\n\n`;
|
||||
|
||||
// Core Web Vitals
|
||||
if (Object.keys(this.metrics.coreWebVitals).length > 0) {
|
||||
report += `## Core Web Vitals\n\n`;
|
||||
report += `| Metric | Score | Value | Status |\n`;
|
||||
report += `|--------|-------|-------|--------|\n`;
|
||||
|
||||
Object.entries(this.metrics.coreWebVitals).forEach(([metric, data]) => {
|
||||
if (data && typeof data === "object" && data.score !== undefined) {
|
||||
const status = this.getMetricStatus(metric, data.score);
|
||||
report += `| ${metric.toUpperCase()} | ${data.score} | ${
|
||||
data.displayValue || "N/A"
|
||||
} | ${status} |\n`;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Bundle Metrics
|
||||
if (Object.keys(this.metrics.bundleMetrics).length > 0) {
|
||||
report += `\n## Bundle Metrics\n\n`;
|
||||
report += `- **Total Size:** ${this.metrics.bundleMetrics.totalSizeMB}MB (${this.metrics.bundleMetrics.totalSizeKB}KB)\n`;
|
||||
report += `- **File Count:** ${this.metrics.bundleMetrics.fileCount}\n`;
|
||||
report += `- **Average Size:** ${this.metrics.bundleMetrics.averageSizeKB}KB per file\n`;
|
||||
}
|
||||
|
||||
// Budget Violations
|
||||
if (
|
||||
this.metrics.budgetViolations &&
|
||||
this.metrics.budgetViolations.length > 0
|
||||
) {
|
||||
report += `\n## Budget Violations\n\n`;
|
||||
this.metrics.budgetViolations.forEach((violation) => {
|
||||
report += `- **${violation.metric}**: ${violation.current} (exceeds ${
|
||||
violation.budget
|
||||
}) - ${violation.severity.toUpperCase()}\n`;
|
||||
});
|
||||
}
|
||||
|
||||
// Recommendations
|
||||
report += `\n## Recommendations\n\n`;
|
||||
report += `- Monitor Core Web Vitals regularly\n`;
|
||||
report += `- Implement code splitting for large bundles\n`;
|
||||
report += `- Use dynamic imports for non-critical components\n`;
|
||||
report += `- Optimize images and fonts\n`;
|
||||
report += `- Enable compression and caching\n`;
|
||||
|
||||
fs.writeFileSync(reportPath, report);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get status emoji for metric score
|
||||
*/
|
||||
getMetricStatus(metric, score) {
|
||||
if (score >= 90) return "✅ Good";
|
||||
if (score >= 50) return "⚠️ Needs Improvement";
|
||||
return "❌ Poor";
|
||||
}
|
||||
}
|
||||
|
||||
// Run the performance monitor if this script is executed directly
|
||||
// Run monitoring if called directly
|
||||
if (require.main === module) {
|
||||
const monitor = new PerformanceMonitorScript();
|
||||
monitor.run();
|
||||
const monitor = new PerformanceMonitor();
|
||||
monitor.monitorPerformance().catch(console.error);
|
||||
}
|
||||
|
||||
module.exports = PerformanceMonitorScript;
|
||||
module.exports = PerformanceMonitor;
|
||||
|
||||
@@ -0,0 +1,335 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Web Vitals Tracker
|
||||
* Real-time monitoring of Core Web Vitals in production
|
||||
*/
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const WEB_VITALS_DIR = path.join(__dirname, "..", ".next", "web-vitals");
|
||||
|
||||
class WebVitalsTracker {
|
||||
constructor() {
|
||||
this.metrics = {
|
||||
timestamp: new Date().toISOString(),
|
||||
vitals: {
|
||||
lcp: [],
|
||||
fid: [],
|
||||
cls: [],
|
||||
fcp: [],
|
||||
ttfb: [],
|
||||
},
|
||||
summary: {},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Track Web Vitals from client-side
|
||||
*/
|
||||
trackWebVitals() {
|
||||
const trackingCode = `
|
||||
// Web Vitals Tracking Script
|
||||
(function() {
|
||||
// Import web-vitals library
|
||||
import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
|
||||
const vitals = {};
|
||||
|
||||
// Track Largest Contentful Paint
|
||||
getLCP((metric) => {
|
||||
vitals.lcp = {
|
||||
value: metric.value,
|
||||
rating: metric.rating,
|
||||
delta: metric.delta,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
sendVitals('lcp', vitals.lcp);
|
||||
});
|
||||
|
||||
// Track First Input Delay
|
||||
getFID((metric) => {
|
||||
vitals.fid = {
|
||||
value: metric.value,
|
||||
rating: metric.rating,
|
||||
delta: metric.delta,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
sendVitals('fid', vitals.fid);
|
||||
});
|
||||
|
||||
// Track Cumulative Layout Shift
|
||||
getCLS((metric) => {
|
||||
vitals.cls = {
|
||||
value: metric.value,
|
||||
rating: metric.rating,
|
||||
delta: metric.delta,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
sendVitals('cls', vitals.cls);
|
||||
});
|
||||
|
||||
// Track First Contentful Paint
|
||||
getFCP((metric) => {
|
||||
vitals.fcp = {
|
||||
value: metric.value,
|
||||
rating: metric.rating,
|
||||
delta: metric.delta,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
sendVitals('fcp', vitals.fcp);
|
||||
});
|
||||
|
||||
// Track Time to First Byte
|
||||
getTTFB((metric) => {
|
||||
vitals.ttfb = {
|
||||
value: metric.value,
|
||||
rating: metric.rating,
|
||||
delta: metric.delta,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
sendVitals('ttfb', vitals.ttfb);
|
||||
});
|
||||
});
|
||||
|
||||
// Send vitals to server
|
||||
function sendVitals(metric, data) {
|
||||
if (typeof window !== 'undefined' && window.fetch) {
|
||||
fetch('/api/web-vitals', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
metric,
|
||||
data,
|
||||
url: window.location.href,
|
||||
userAgent: navigator.userAgent,
|
||||
timestamp: Date.now()
|
||||
})
|
||||
}).catch(console.error);
|
||||
}
|
||||
}
|
||||
})();
|
||||
`;
|
||||
|
||||
return trackingCode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create API endpoint for receiving Web Vitals
|
||||
*/
|
||||
createAPIEndpoint() {
|
||||
const apiCode = `
|
||||
// API endpoint for Web Vitals tracking
|
||||
export default function handler(req, res) {
|
||||
if (req.method !== 'POST') {
|
||||
return res.status(405).json({ error: 'Method not allowed' });
|
||||
}
|
||||
|
||||
try {
|
||||
const { metric, data, url, userAgent, timestamp } = req.body;
|
||||
|
||||
// Store the metric data
|
||||
const vitalsData = {
|
||||
metric,
|
||||
data,
|
||||
url,
|
||||
userAgent,
|
||||
timestamp: new Date(timestamp).toISOString()
|
||||
};
|
||||
|
||||
// In production, you would save this to a database
|
||||
// For now, we'll log it
|
||||
console.log('Web Vital received:', vitalsData);
|
||||
|
||||
res.status(200).json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Error processing web vital:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
return apiCode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate Web Vitals dashboard
|
||||
*/
|
||||
generateDashboard() {
|
||||
const dashboardCode = `
|
||||
import React, { useState, useEffect } from 'react';
|
||||
|
||||
const WebVitalsDashboard = () => {
|
||||
const [vitals, setVitals] = useState({
|
||||
lcp: { value: 0, rating: 'unknown' },
|
||||
fid: { value: 0, rating: 'unknown' },
|
||||
cls: { value: 0, rating: 'unknown' },
|
||||
fcp: { value: 0, rating: 'unknown' },
|
||||
ttfb: { value: 0, rating: 'unknown' }
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
// In a real implementation, you would fetch from your database
|
||||
// For now, we'll use localStorage for demo purposes
|
||||
const storedVitals = localStorage.getItem('web-vitals');
|
||||
if (storedVitals) {
|
||||
setVitals(JSON.parse(storedVitals));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const getRatingColor = (rating) => {
|
||||
switch (rating) {
|
||||
case 'good': return 'text-green-600';
|
||||
case 'needs-improvement': return 'text-yellow-600';
|
||||
case 'poor': return 'text-red-600';
|
||||
default: return 'text-gray-600';
|
||||
}
|
||||
};
|
||||
|
||||
const getRatingIcon = (rating) => {
|
||||
switch (rating) {
|
||||
case 'good': return '✅';
|
||||
case 'needs-improvement': return '⚠️';
|
||||
case 'poor': return '❌';
|
||||
default: return '❓';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 bg-white rounded-lg shadow-lg">
|
||||
<h2 className="text-2xl font-bold mb-6">Web Vitals Dashboard</h2>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{Object.entries(vitals).map(([metric, data]) => (
|
||||
<div key={metric} className="p-4 border rounded-lg">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="font-semibold text-lg">{metric.toUpperCase()}</h3>
|
||||
<span className="text-2xl">{getRatingIcon(data.rating)}</span>
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">
|
||||
<div>Value: {data.value}ms</div>
|
||||
<div className={\`font-medium \${getRatingColor(data.rating)}\`}>
|
||||
Rating: {data.rating.replace('-', ' ')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 p-4 bg-gray-50 rounded-lg">
|
||||
<h3 className="font-semibold mb-2">Performance Guidelines</h3>
|
||||
<ul className="text-sm space-y-1">
|
||||
<li>• <strong>LCP:</strong> Good < 2.5s, Needs Improvement 2.5-4s, Poor > 4s</li>
|
||||
<li>• <strong>FID:</strong> Good < 100ms, Needs Improvement 100-300ms, Poor > 300ms</li>
|
||||
<li>• <strong>CLS:</strong> Good < 0.1, Needs Improvement 0.1-0.25, Poor > 0.25</li>
|
||||
<li>• <strong>FCP:</strong> Good < 1.8s, Needs Improvement 1.8-3s, Poor > 3s</li>
|
||||
<li>• <strong>TTFB:</strong> Good < 800ms, Needs Improvement 800-1800ms, Poor > 1800ms</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default WebVitalsDashboard;
|
||||
`;
|
||||
|
||||
return dashboardCode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save Web Vitals data
|
||||
*/
|
||||
saveVitalsData(metric, data) {
|
||||
if (!fs.existsSync(WEB_VITALS_DIR)) {
|
||||
fs.mkdirSync(WEB_VITALS_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
const filePath = path.join(WEB_VITALS_DIR, `${metric}.json`);
|
||||
let existingData = [];
|
||||
|
||||
if (fs.existsSync(filePath)) {
|
||||
try {
|
||||
existingData = JSON.parse(fs.readFileSync(filePath, "utf8"));
|
||||
} catch (error) {
|
||||
console.warn("Could not parse existing vitals data:", error.message);
|
||||
}
|
||||
}
|
||||
|
||||
existingData.push({
|
||||
...data,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// Keep only last 100 entries
|
||||
if (existingData.length > 100) {
|
||||
existingData = existingData.slice(-100);
|
||||
}
|
||||
|
||||
fs.writeFileSync(filePath, JSON.stringify(existingData, null, 2));
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate Web Vitals report
|
||||
*/
|
||||
generateReport() {
|
||||
if (!fs.existsSync(WEB_VITALS_DIR)) {
|
||||
console.log("No Web Vitals data found");
|
||||
return;
|
||||
}
|
||||
|
||||
const files = fs.readdirSync(WEB_VITALS_DIR);
|
||||
const report = {
|
||||
timestamp: new Date().toISOString(),
|
||||
metrics: {},
|
||||
};
|
||||
|
||||
files.forEach((file) => {
|
||||
if (file.endsWith(".json")) {
|
||||
const metric = file.replace(".json", "");
|
||||
const data = JSON.parse(
|
||||
fs.readFileSync(path.join(WEB_VITALS_DIR, file), "utf8")
|
||||
);
|
||||
|
||||
if (data.length > 0) {
|
||||
const values = data
|
||||
.map((d) => d.value)
|
||||
.filter((v) => v !== undefined);
|
||||
const ratings = data
|
||||
.map((d) => d.rating)
|
||||
.filter((r) => r !== undefined);
|
||||
|
||||
report.metrics[metric] = {
|
||||
count: data.length,
|
||||
average:
|
||||
values.length > 0
|
||||
? Math.round(values.reduce((a, b) => a + b, 0) / values.length)
|
||||
: 0,
|
||||
min: values.length > 0 ? Math.min(...values) : 0,
|
||||
max: values.length > 0 ? Math.max(...values) : 0,
|
||||
goodCount: ratings.filter((r) => r === "good").length,
|
||||
needsImprovementCount: ratings.filter(
|
||||
(r) => r === "needs-improvement"
|
||||
).length,
|
||||
poorCount: ratings.filter((r) => r === "poor").length,
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const reportPath = path.join(WEB_VITALS_DIR, "report.json");
|
||||
fs.writeFileSync(reportPath, JSON.stringify(report, null, 2));
|
||||
|
||||
console.log("📊 Web Vitals report generated:", reportPath);
|
||||
return report;
|
||||
}
|
||||
}
|
||||
|
||||
// Run if called directly
|
||||
if (require.main === module) {
|
||||
const tracker = new WebVitalsTracker();
|
||||
tracker.generateReport();
|
||||
}
|
||||
|
||||
module.exports = WebVitalsTracker;
|
||||
Reference in New Issue
Block a user