Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,8 @@
"check-i18n": "node scripts/check-i18n.cjs",
"check-locales": "node scripts/check-locales.mjs",
"prebuild": "pnpm run check-locales && pnpm run check-i18n",
"generate:sitemap": "npx tsx scripts/generate-sitemap.ts"
"generate:sitemap": "npx tsx scripts/generate-sitemap.ts",
"migrate": "npx tsx src/lib/db/migrate.ts"
},
"dependencies": {
"@apollo/client": "^3.8.0",
Expand Down
167 changes: 143 additions & 24 deletions src/app/api/performance/vitals/route.ts
Original file line number Diff line number Diff line change
@@ -1,44 +1,163 @@
import { NextResponse } from 'next/server';
import { NextRequest, NextResponse } from 'next/server';
import { edgeLog } from '@/../infra/edge-config';
import { query } from '@/lib/db/pool';

export const runtime = 'edge';
export const runtime = 'nodejs';

export async function POST(request: Request) {
const RANGE_INTERVALS: Record<string, string> = {
'7d': '7 days',
'30d': '30 days',
'90d': '90 days',
all: '100 years',
};

const POOR_ALERT_THRESHOLD_PCT = 5;
const POOR_CHECK_SESSION_LIMIT = 500;

interface VitalsBody {
name: string;
value: number;
rating: string;
url?: string;
timestamp?: number;
id?: string;
delta?: number;
navigationType?: string;
userAgent?: string;
}

function parseRange(range: string | null): string {
if (!range || !RANGE_INTERVALS[range]) return RANGE_INTERVALS['7d'];
return RANGE_INTERVALS[range];
}

function validateMetric(body: unknown): body is VitalsBody {
if (!body || typeof body !== 'object') return false;
const m = body as Record<string, unknown>;
return (
typeof m.name === 'string' &&
typeof m.value === 'number' &&
Number.isFinite(m.value) &&
typeof m.rating === 'string' &&
['good', 'needs-improvement', 'poor'].includes(m.rating)
);
}

async function checkPoorRate(name: string): Promise<void> {
const result = await query(
`
SELECT
COUNT(*) AS total,
SUM(CASE WHEN rating = 'poor' THEN 1 ELSE 0 END) AS poor_count
FROM (
SELECT rating FROM web_vitals
WHERE name = $1
ORDER BY created_at DESC
LIMIT $2
) recent
`,
[name, POOR_CHECK_SESSION_LIMIT],
);

const row = result.rows[0] as { total: number; poor_count: number } | undefined;
if (!row || row.total === 0) return;

const poorPct = (row.poor_count / row.total) * 100;
if (poorPct > POOR_ALERT_THRESHOLD_PCT) {
console.warn(
`[PERFORMANCE ALERT] "${name}" poor-rate ${poorPct.toFixed(
1,
)}% exceeds ${POOR_ALERT_THRESHOLD_PCT}% threshold (${row.poor_count}/${
row.total
} recent sessions)`,
);
}
}

export async function POST(request: NextRequest) {
edgeLog('info', '/api/performance/vitals', 'POST request received');
try {
const metric = await request.json();

// Log the received metric
console.log('[Performance Analytics] Received metric:', {
name: metric.name,
value: metric.value,
rating: metric.rating,
url: metric.url,
timestamp: new Date(metric.timestamp).toISOString(),
});
const body: unknown = await request.json();

// Implement alerting logic
if (metric.rating === 'poor') {
if (!validateMetric(body)) {
return NextResponse.json(
{ success: false, message: 'Invalid metric payload' },
{ status: 400 },
);
}

const { name, value, rating, url, timestamp } = body;

const result = await query(
`INSERT INTO web_vitals (name, value, rating, page_url, created_at)
VALUES ($1, $2, $3, $4, to_timestamp($5::double precision / 1000))
RETURNING id`,
[name, value, rating, url ?? '/', timestamp ?? Date.now()],
);

const insertedId = result.rows[0]?.id as string | undefined;

if (rating === 'poor') {
console.warn(
`[PERFORMANCE ALERT] Critical degradation detected for ${metric.name} on ${metric.url}. Value: ${metric.value}`,
`[PERFORMANCE ALERT] Critical degradation detected for ${name} on ${
url ?? '/'
}. Value: ${value}`,
);
// In a real app, this could trigger a Slack notification, PagerDuty, etc.
} else if (metric.rating === 'needs-improvement') {
await checkPoorRate(name);
} else if (rating === 'needs-improvement') {
console.info(
`[PERFORMANCE WARNING] ${metric.name} needs improvement on ${metric.url}. Value: ${metric.value}`,
`[PERFORMANCE WARNING] ${name} needs improvement on ${url ?? '/'}. Value: ${value}`,
);
}

// In a real app, you would store this in a database like PostgreSQL, ClickHouse, or InfluxDB.
// For this demonstration, we'll just acknowledge receipt.

return NextResponse.json({
success: true,
message: 'Metric received and processed',
alertTriggered: metric.rating === 'poor',
message: 'Metric received and persisted',
id: insertedId,
alertTriggered: rating === 'poor',
});
} catch (error) {
console.error('[Performance Analytics] Error processing metric:', error);
return NextResponse.json({ success: false, message: 'Internal Server Error' }, { status: 500 });
}
}

export async function GET(request: NextRequest) {
edgeLog('info', '/api/performance/vitals', 'GET request received');
try {
const { searchParams } = new URL(request.url);
const range = searchParams.get('range');
const interval = parseRange(range);

const result = await query(
`
SELECT
name,
page_url,
COUNT(*)::int AS total_sessions,
ROUND(AVG(value)::numeric, 4)::float8 AS avg_value,
ROUND(
SUM(CASE WHEN rating = 'poor' THEN 1 ELSE 0 END) * 100.0 / COUNT(*),
2
)::float8 AS poor_rate_pct
FROM web_vitals
WHERE created_at > NOW() - $1::interval
GROUP BY name, page_url
ORDER BY poor_rate_pct DESC, total_sessions DESC
`,
[interval],
);

return NextResponse.json({
success: true,
data: result.rows,
range: range ?? '7d',
});
} catch (error) {
console.error('[Performance Analytics] Error fetching vitals:', error);
return NextResponse.json(
{ success: false, message: 'Failed to fetch metrics' },
{ status: 500 },
);
}
}
58 changes: 58 additions & 0 deletions src/lib/db/migrate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { dbPool, query } from './pool';

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

const MIGRATIONS_TABLE = '_migrations';
const MIGRATIONS_DIR = path.resolve(__dirname, 'migrations');

async function ensureMigrationsTable(): Promise<void> {
await query(`
CREATE TABLE IF NOT EXISTS ${MIGRATIONS_TABLE} (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL UNIQUE,
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
`);
}

async function getAppliedMigrations(): Promise<Set<string>> {
const result = await query(`SELECT name FROM ${MIGRATIONS_TABLE} ORDER BY id`);
return new Set(result.rows.map((r: { name: string }) => r.name));
}

async function applyMigration(name: string, sql: string): Promise<void> {
await query(sql);
await query(`INSERT INTO ${MIGRATIONS_TABLE} (name) VALUES ($1)`, [name]);
}

export async function runMigrations(): Promise<void> {
await ensureMigrationsTable();
const applied = await getAppliedMigrations();
const files = fs
.readdirSync(MIGRATIONS_DIR)
.filter((f) => f.endsWith('.sql'))
.sort();

for (const file of files) {
if (applied.has(file)) continue;
const sql = fs.readFileSync(path.join(MIGRATIONS_DIR, file), 'utf-8');
await applyMigration(file, sql);
}
}

const isMain = process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1]);

if (isMain) {
runMigrations()
.then(() => {
process.exit(0);
})
.catch((err) => {
console.error('Migration failed:', err);
process.exit(1);
});
}
14 changes: 14 additions & 0 deletions src/lib/db/migrations/001_create_web_vitals.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
CREATE TABLE IF NOT EXISTS web_vitals (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(50) NOT NULL,
value DOUBLE PRECISION NOT NULL,
rating VARCHAR(20) NOT NULL,
page_url TEXT NOT NULL,
user_id UUID,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX IF NOT EXISTS idx_web_vitals_name ON web_vitals (name);
CREATE INDEX IF NOT EXISTS idx_web_vitals_page_url ON web_vitals (page_url);
CREATE INDEX IF NOT EXISTS idx_web_vitals_created_at ON web_vitals (created_at);
CREATE INDEX IF NOT EXISTS idx_web_vitals_rating ON web_vitals (rating);
1 change: 0 additions & 1 deletion tsconfig.tsbuildinfo

This file was deleted.

Loading