Web vitals: prefer external RUM
This commit is contained in:
@@ -13,3 +13,10 @@ SMTP_FROM="Community Rule <noreply@localhost>"
|
||||
|
||||
# Set to `true` to sync the create-flow draft with `/api/drafts/me` when the user is signed in.
|
||||
NEXT_PUBLIC_ENABLE_BACKEND_SYNC=
|
||||
|
||||
# Web vitals API (CR-80): `external` = structured logs only, no writes under `.next` (default in production).
|
||||
# `local` = file-based aggregates under `.next/web-vitals` (default in development). Omit to use defaults.
|
||||
# WEB_VITALS_STORAGE=external
|
||||
|
||||
# Optional: URL shown on /monitor when using external storage (Grafana, Kibana, vendor RUM, etc.).
|
||||
# NEXT_PUBLIC_RUM_DASHBOARD_URL=
|
||||
|
||||
+1
-1
@@ -35,7 +35,7 @@ Use `npx prisma studio` to inspect the database.
|
||||
| GET / POST | `/api/rules` | List or publish rules. |
|
||||
| GET | `/api/templates` | List curated templates. Optional repeatable `facet.<group>=<value>` query params re-rank results (and may include `scores` in the JSON). See [docs/guides/template-recommendation-matrix.md](docs/guides/template-recommendation-matrix.md) §9.1. |
|
||||
| GET | `/api/create-flow/methods` | Facet-aware scores for custom-rule card steps: required `section` (`communication` \| `membership` \| `decisionApproaches` \| `conflictManagement`) and optional `facet.*` params (same facet groups as `/api/templates`). Returns `methods` with match metadata for re-ordering in the wizard. |
|
||||
| POST / GET | `/api/web-vitals` | Ingest or read aggregated web vitals (file-based store under `.next` today; not ideal for multi-instance — see [docs/guides/backend-roadmap.md](docs/guides/backend-roadmap.md) §7). |
|
||||
| POST / GET | `/api/web-vitals` | Ingest or read web vitals. **Production default:** `external` — structured logs only (no writes under `.next`; safe for read-only FS). **Development default:** `local` — aggregates under `.next/web-vitals`. Override with `WEB_VITALS_STORAGE`. See [docs/guides/backend-roadmap.md](docs/guides/backend-roadmap.md) §7. |
|
||||
|
||||
### Magic-link sign-in
|
||||
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
"use client";
|
||||
|
||||
import WebVitalsDashboard from "../../components/sections/WebVitalsDashboard";
|
||||
import TopNav from "../../components/navigation/TopNav";
|
||||
import Footer from "../../components/navigation/Footer";
|
||||
import { useMessages } from "../../contexts/MessagesContext";
|
||||
|
||||
export default function MonitorPageContent() {
|
||||
const m = useMessages();
|
||||
const p = m.pages.monitor;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[var(--color-surface-default-primary)]">
|
||||
<TopNav folderTop={false} />
|
||||
|
||||
<main className="container mx-auto px-[var(--spacing-scale-024)] py-[var(--spacing-scale-032)]">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="mb-[var(--spacing-scale-032)]">
|
||||
<h1 className="text-4xl font-bold text-[var(--color-content-default-primary)] mb-[var(--spacing-scale-016)]">
|
||||
{p.title}
|
||||
</h1>
|
||||
<p className="text-[var(--font-size-body-large)] text-[var(--color-content-default-secondary)]">
|
||||
{p.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-[var(--spacing-scale-032)] mb-[var(--spacing-scale-032)]">
|
||||
<div className="p-[var(--spacing-scale-024)] bg-[var(--color-surface-default-secondary)] rounded-[var(--radius-measures-radius-medium)]">
|
||||
<h2 className="text-2xl font-semibold mb-[var(--spacing-scale-016)] text-[var(--color-content-default-primary)]">
|
||||
{p.performanceTargets.title}
|
||||
</h2>
|
||||
<div className="space-y-[var(--spacing-scale-012)]">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-[var(--font-size-body-medium)]">
|
||||
{p.performanceTargets.loadTime}
|
||||
</span>
|
||||
<span className="font-semibold text-green-600">
|
||||
{p.performanceTargets.loadTimeTarget}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-[var(--font-size-body-medium)]">
|
||||
{p.performanceTargets.lcp}
|
||||
</span>
|
||||
<span className="font-semibold text-green-600">
|
||||
{p.performanceTargets.lcpTarget}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-[var(--font-size-body-medium)]">
|
||||
{p.performanceTargets.fid}
|
||||
</span>
|
||||
<span className="font-semibold text-green-600">
|
||||
{p.performanceTargets.fidTarget}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-[var(--font-size-body-medium)]">
|
||||
{p.performanceTargets.cls}
|
||||
</span>
|
||||
<span className="font-semibold text-green-600">
|
||||
{p.performanceTargets.clsTarget}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-[var(--font-size-body-medium)]">
|
||||
{p.performanceTargets.lighthouse}
|
||||
</span>
|
||||
<span className="font-semibold text-green-600">
|
||||
{p.performanceTargets.lighthouseTarget}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-[var(--spacing-scale-024)] bg-[var(--color-surface-default-secondary)] rounded-[var(--radius-measures-radius-medium)]">
|
||||
<h2 className="text-2xl font-semibold mb-[var(--spacing-scale-016)] text-[var(--color-content-default-primary)]">
|
||||
{p.optimizationStatus.title}
|
||||
</h2>
|
||||
<div className="space-y-[var(--spacing-scale-012)]">
|
||||
<div className="flex items-center gap-[var(--spacing-scale-008)]">
|
||||
<span className="text-green-600">✅</span>
|
||||
<span className="text-[var(--font-size-body-medium)]">
|
||||
{p.optimizationStatus.codeSplitting}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-[var(--spacing-scale-008)]">
|
||||
<span className="text-green-600">✅</span>
|
||||
<span className="text-[var(--font-size-body-medium)]">
|
||||
{p.optimizationStatus.reactMemo}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-[var(--spacing-scale-008)]">
|
||||
<span className="text-green-600">✅</span>
|
||||
<span className="text-[var(--font-size-body-medium)]">
|
||||
{p.optimizationStatus.imageOptimization}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-[var(--spacing-scale-008)]">
|
||||
<span className="text-green-600">✅</span>
|
||||
<span className="text-[var(--font-size-body-medium)]">
|
||||
{p.optimizationStatus.fontPreloading}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-[var(--spacing-scale-008)]">
|
||||
<span className="text-green-600">✅</span>
|
||||
<span className="text-[var(--font-size-body-medium)]">
|
||||
{p.optimizationStatus.bundleAnalysis}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-[var(--spacing-scale-008)]">
|
||||
<span className="text-green-600">✅</span>
|
||||
<span className="text-[var(--font-size-body-medium)]">
|
||||
{p.optimizationStatus.errorBoundaries}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<WebVitalsDashboard />
|
||||
|
||||
<div className="mt-[var(--spacing-scale-032)] p-[var(--spacing-scale-024)] bg-[var(--color-surface-default-secondary)] rounded-[var(--radius-measures-radius-medium)]">
|
||||
<h2 className="text-2xl font-semibold mb-[var(--spacing-scale-016)] text-[var(--color-content-default-primary)]">
|
||||
{p.monitoringCommands.title}
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-[var(--spacing-scale-016)]">
|
||||
<div>
|
||||
<h3 className="font-semibold mb-[var(--spacing-scale-008)] text-[var(--color-content-default-primary)]">
|
||||
{p.monitoringCommands.bundleAnalyze.title}
|
||||
</h3>
|
||||
<code className="block p-[var(--spacing-scale-008)] bg-[var(--color-surface-inverse-brand-primary)] text-[var(--color-content-inverse-primary)] rounded-[var(--radius-measures-radius-small)] text-sm">
|
||||
{p.monitoringCommands.bundleAnalyze.command}
|
||||
</code>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold mb-[var(--spacing-scale-008)] text-[var(--color-content-default-primary)]">
|
||||
{p.monitoringCommands.e2ePerformance.title}
|
||||
</h3>
|
||||
<code className="block p-[var(--spacing-scale-008)] bg-[var(--color-surface-inverse-brand-primary)] text-[var(--color-content-inverse-primary)] rounded-[var(--radius-measures-radius-small)] text-sm">
|
||||
{p.monitoringCommands.e2ePerformance.command}
|
||||
</code>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold mb-[var(--spacing-scale-008)] text-[var(--color-content-default-primary)]">
|
||||
{p.monitoringCommands.lhciDesktop.title}
|
||||
</h3>
|
||||
<code className="block p-[var(--spacing-scale-008)] bg-[var(--color-surface-inverse-brand-primary)] text-[var(--color-content-inverse-primary)] rounded-[var(--radius-measures-radius-small)] text-sm">
|
||||
{p.monitoringCommands.lhciDesktop.command}
|
||||
</code>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold mb-[var(--spacing-scale-008)] text-[var(--color-content-default-primary)]">
|
||||
{p.monitoringCommands.performanceBudget.title}
|
||||
</h3>
|
||||
<code className="block p-[var(--spacing-scale-008)] bg-[var(--color-surface-inverse-brand-primary)] text-[var(--color-content-inverse-primary)] rounded-[var(--radius-measures-radius-small)] text-sm">
|
||||
{p.monitoringCommands.performanceBudget.command}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,159 +1,5 @@
|
||||
import WebVitalsDashboard from "../../components/sections/WebVitalsDashboard";
|
||||
import TopNav from "../../components/navigation/TopNav";
|
||||
import Footer from "../../components/navigation/Footer";
|
||||
import MonitorPageContent from "./MonitorPageContent";
|
||||
|
||||
export default function MonitorPage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-[var(--color-surface-default-primary)]">
|
||||
<TopNav folderTop={false} />
|
||||
|
||||
<main className="container mx-auto px-[var(--spacing-scale-024)] py-[var(--spacing-scale-032)]">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="mb-[var(--spacing-scale-032)]">
|
||||
<h1 className="text-4xl font-bold text-[var(--color-content-default-primary)] mb-[var(--spacing-scale-016)]">
|
||||
Performance Monitoring
|
||||
</h1>
|
||||
<p className="text-[var(--font-size-body-large)] text-[var(--color-content-default-secondary)]">
|
||||
Real-time monitoring of Core Web Vitals and performance metrics
|
||||
for Community Rule 3.0
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-[var(--spacing-scale-032)] mb-[var(--spacing-scale-032)]">
|
||||
<div className="p-[var(--spacing-scale-024)] bg-[var(--color-surface-default-secondary)] rounded-[var(--radius-measures-radius-medium)]">
|
||||
<h2 className="text-2xl font-semibold mb-[var(--spacing-scale-016)] text-[var(--color-content-default-primary)]">
|
||||
Performance Targets
|
||||
</h2>
|
||||
<div className="space-y-[var(--spacing-scale-012)]">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-[var(--font-size-body-medium)]">
|
||||
Load Time
|
||||
</span>
|
||||
<span className="font-semibold text-green-600">
|
||||
< 2 seconds
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-[var(--font-size-body-medium)]">
|
||||
LCP
|
||||
</span>
|
||||
<span className="font-semibold text-green-600">
|
||||
< 2.5s
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-[var(--font-size-body-medium)]">
|
||||
FID
|
||||
</span>
|
||||
<span className="font-semibold text-green-600">
|
||||
< 100ms
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-[var(--font-size-body-medium)]">
|
||||
CLS
|
||||
</span>
|
||||
<span className="font-semibold text-green-600">< 0.1</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-[var(--font-size-body-medium)]">
|
||||
Lighthouse Score
|
||||
</span>
|
||||
<span className="font-semibold text-green-600">> 90</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-[var(--spacing-scale-024)] bg-[var(--color-surface-default-secondary)] rounded-[var(--radius-measures-radius-medium)]">
|
||||
<h2 className="text-2xl font-semibold mb-[var(--spacing-scale-016)] text-[var(--color-content-default-primary)]">
|
||||
Optimization Status
|
||||
</h2>
|
||||
<div className="space-y-[var(--spacing-scale-012)]">
|
||||
<div className="flex items-center gap-[var(--spacing-scale-008)]">
|
||||
<span className="text-green-600">✅</span>
|
||||
<span className="text-[var(--font-size-body-medium)]">
|
||||
Code Splitting & Dynamic Imports
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-[var(--spacing-scale-008)]">
|
||||
<span className="text-green-600">✅</span>
|
||||
<span className="text-[var(--font-size-body-medium)]">
|
||||
React.memo Optimizations
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-[var(--spacing-scale-008)]">
|
||||
<span className="text-green-600">✅</span>
|
||||
<span className="text-[var(--font-size-body-medium)]">
|
||||
Image Optimization
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-[var(--spacing-scale-008)]">
|
||||
<span className="text-green-600">✅</span>
|
||||
<span className="text-[var(--font-size-body-medium)]">
|
||||
Font Preloading
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-[var(--spacing-scale-008)]">
|
||||
<span className="text-green-600">✅</span>
|
||||
<span className="text-[var(--font-size-body-medium)]">
|
||||
Bundle Analysis
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-[var(--spacing-scale-008)]">
|
||||
<span className="text-green-600">✅</span>
|
||||
<span className="text-[var(--font-size-body-medium)]">
|
||||
Error Boundaries
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<WebVitalsDashboard />
|
||||
|
||||
<div className="mt-[var(--spacing-scale-032)] p-[var(--spacing-scale-024)] bg-[var(--color-surface-default-secondary)] rounded-[var(--radius-measures-radius-medium)]">
|
||||
<h2 className="text-2xl font-semibold mb-[var(--spacing-scale-016)] text-[var(--color-content-default-primary)]">
|
||||
Monitoring Commands
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-[var(--spacing-scale-016)]">
|
||||
<div>
|
||||
<h3 className="font-semibold mb-[var(--spacing-scale-008)] text-[var(--color-content-default-primary)]">
|
||||
Bundle Analysis
|
||||
</h3>
|
||||
<code className="block p-[var(--spacing-scale-008)] bg-[var(--color-surface-inverse-brand-primary)] text-[var(--color-content-inverse-primary)] rounded-[var(--radius-measures-radius-small)] text-sm">
|
||||
npm run bundle:analyze
|
||||
</code>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold mb-[var(--spacing-scale-008)] text-[var(--color-content-default-primary)]">
|
||||
Performance Monitor
|
||||
</h3>
|
||||
<code className="block p-[var(--spacing-scale-008)] bg-[var(--color-surface-inverse-brand-primary)] text-[var(--color-content-inverse-primary)] rounded-[var(--radius-measures-radius-small)] text-sm">
|
||||
npm run performance:monitor
|
||||
</code>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold mb-[var(--spacing-scale-008)] text-[var(--color-content-default-primary)]">
|
||||
Web Vitals Tracking
|
||||
</h3>
|
||||
<code className="block p-[var(--spacing-scale-008)] bg-[var(--color-surface-inverse-brand-primary)] text-[var(--color-content-inverse-primary)] rounded-[var(--radius-measures-radius-small)] text-sm">
|
||||
npm run web-vitals:track
|
||||
</code>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold mb-[var(--spacing-scale-008)] text-[var(--color-content-default-primary)]">
|
||||
All Monitoring
|
||||
</h3>
|
||||
<code className="block p-[var(--spacing-scale-008)] bg-[var(--color-surface-inverse-brand-primary)] text-[var(--color-content-inverse-primary)] rounded-[var(--radius-measures-radius-small)] text-sm">
|
||||
npm run monitor:all
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
return <MonitorPageContent />;
|
||||
}
|
||||
|
||||
+55
-108
@@ -1,90 +1,71 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { logger } from "../../../lib/logger";
|
||||
import { getWebVitalsStorageMode } from "../../../lib/server/webVitals/mode";
|
||||
import {
|
||||
appendLocalWebVital,
|
||||
readLocalAggregatedMetrics,
|
||||
type WebVitalData,
|
||||
} from "../../../lib/server/webVitals/localFileStore";
|
||||
import { readLimitedJson } from "../../../lib/server/validation/requestBody";
|
||||
import { webVitalIngestSchema } from "../../../lib/server/validation/webVitalsSchema";
|
||||
import { jsonFromZodError } from "../../../lib/server/validation/zodHttp";
|
||||
|
||||
const WEB_VITALS_DIR = path.join(process.cwd(), ".next", "web-vitals");
|
||||
|
||||
interface WebVitalData {
|
||||
metric: string;
|
||||
data: {
|
||||
value: number;
|
||||
rating: string;
|
||||
};
|
||||
url: string;
|
||||
userAgent: string;
|
||||
timestamp: string;
|
||||
receivedAt: string;
|
||||
function normalizeTimestamp(raw: string | number): string {
|
||||
if (typeof raw === "number" && Number.isFinite(raw)) {
|
||||
return new Date(raw).toISOString();
|
||||
}
|
||||
return new Date(raw).toISOString();
|
||||
}
|
||||
|
||||
interface WebVitalMetrics {
|
||||
[metric: string]: {
|
||||
count: number;
|
||||
average: number;
|
||||
min: number;
|
||||
max: number;
|
||||
goodCount: number;
|
||||
needsImprovementCount: number;
|
||||
poorCount: number;
|
||||
lastUpdated: string;
|
||||
};
|
||||
}
|
||||
|
||||
// Ensure web-vitals directory exists
|
||||
if (!fs.existsSync(WEB_VITALS_DIR)) {
|
||||
fs.mkdirSync(WEB_VITALS_DIR, { recursive: true });
|
||||
function logExternalIngest(body: WebVitalData): void {
|
||||
const line = JSON.stringify({
|
||||
kind: "web_vital_ingest",
|
||||
metric: body.metric,
|
||||
value: body.data.value,
|
||||
rating: body.data.rating,
|
||||
url: body.url,
|
||||
receivedAt: body.receivedAt,
|
||||
});
|
||||
logger.info(line);
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { metric, data, url, userAgent, timestamp } = body as {
|
||||
metric: string;
|
||||
data: { value: number; rating: string };
|
||||
url: string;
|
||||
userAgent: string;
|
||||
timestamp: string;
|
||||
};
|
||||
const limited = await readLimitedJson(request);
|
||||
if (limited.ok === false) {
|
||||
return limited.response;
|
||||
}
|
||||
|
||||
const parsed = webVitalIngestSchema.safeParse(limited.value);
|
||||
if (!parsed.success) return jsonFromZodError(parsed.error);
|
||||
|
||||
const body = parsed.data;
|
||||
|
||||
// Store the metric data
|
||||
const vitalsData: WebVitalData = {
|
||||
metric,
|
||||
data,
|
||||
url,
|
||||
userAgent,
|
||||
timestamp: new Date(timestamp).toISOString(),
|
||||
metric: body.metric,
|
||||
data: {
|
||||
value: body.data.value,
|
||||
rating: body.data.rating,
|
||||
},
|
||||
url: body.url,
|
||||
userAgent: body.userAgent,
|
||||
timestamp: normalizeTimestamp(body.timestamp),
|
||||
receivedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// Save to file (in production, you would save to a database)
|
||||
const filePath = path.join(WEB_VITALS_DIR, `${metric}.json`);
|
||||
let existingData: WebVitalData[] = [];
|
||||
const mode = getWebVitalsStorageMode();
|
||||
|
||||
if (fs.existsSync(filePath)) {
|
||||
try {
|
||||
const fileContent = fs.readFileSync(filePath, "utf8");
|
||||
existingData = JSON.parse(fileContent) as WebVitalData[];
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
logger.warn("Could not parse existing vitals data:", err.message);
|
||||
}
|
||||
if (mode === "external") {
|
||||
logExternalIngest(vitalsData);
|
||||
return NextResponse.json({ success: true, storage: "external" });
|
||||
}
|
||||
|
||||
existingData.push(vitalsData);
|
||||
|
||||
// Keep only last 100 entries per metric
|
||||
if (existingData.length > 100) {
|
||||
existingData = existingData.slice(-100);
|
||||
}
|
||||
|
||||
fs.writeFileSync(filePath, JSON.stringify(existingData, null, 2));
|
||||
|
||||
// Log for monitoring
|
||||
appendLocalWebVital(vitalsData);
|
||||
logger.info(
|
||||
`Web Vital received: ${metric} = ${data.value}ms (${data.rating})`,
|
||||
`Web Vital received: ${body.metric} = ${body.data.value}ms (${body.data.rating})`,
|
||||
);
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
return NextResponse.json({ success: true, storage: "local" });
|
||||
} catch (error) {
|
||||
logger.error("Error processing web vital:", error);
|
||||
return NextResponse.json(
|
||||
@@ -96,51 +77,17 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const metrics: WebVitalMetrics = {};
|
||||
const mode = getWebVitalsStorageMode();
|
||||
|
||||
if (fs.existsSync(WEB_VITALS_DIR)) {
|
||||
const files = fs.readdirSync(WEB_VITALS_DIR);
|
||||
|
||||
files.forEach((file) => {
|
||||
if (file.endsWith(".json")) {
|
||||
const metric = file.replace(".json", "");
|
||||
const fileContent = fs.readFileSync(
|
||||
path.join(WEB_VITALS_DIR, file),
|
||||
"utf8",
|
||||
);
|
||||
const data = JSON.parse(fileContent) as WebVitalData[];
|
||||
|
||||
if (data.length > 0) {
|
||||
const values = data
|
||||
.map((d) => d.data.value)
|
||||
.filter((v) => v !== undefined);
|
||||
const ratings = data
|
||||
.map((d) => d.data.rating)
|
||||
.filter((r) => r !== undefined);
|
||||
|
||||
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,
|
||||
lastUpdated: data[data.length - 1]?.receivedAt || "",
|
||||
};
|
||||
}
|
||||
}
|
||||
if (mode === "external") {
|
||||
return NextResponse.json({
|
||||
metrics: {},
|
||||
storage: "external" as const,
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({ metrics });
|
||||
const metrics = readLocalAggregatedMetrics();
|
||||
return NextResponse.json({ metrics, storage: "local" as const });
|
||||
} catch (error) {
|
||||
logger.error("Error fetching web vitals:", error);
|
||||
return NextResponse.json(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { memo, useEffect, useState } from "react";
|
||||
import { useMessages } from "../../../contexts/MessagesContext";
|
||||
import { logger } from "../../../../lib/logger";
|
||||
import WebVitalsDashboardView from "./WebVitalsDashboard.view";
|
||||
import type { Metrics, Vitals, VitalData } from "./WebVitalsDashboard.types";
|
||||
@@ -18,17 +19,55 @@ const createInitialVitals = (): Vitals => ({
|
||||
ttfb: createInitialVital(),
|
||||
});
|
||||
|
||||
function reportWebVitalToApi(
|
||||
metric: keyof Vitals,
|
||||
value: number,
|
||||
rating: VitalData["rating"],
|
||||
): void {
|
||||
if (typeof window === "undefined") return;
|
||||
if (rating === "unknown") return;
|
||||
|
||||
const body = {
|
||||
metric,
|
||||
data: { value, rating },
|
||||
url: window.location.href,
|
||||
userAgent: navigator.userAgent,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
void fetch("/api/web-vitals", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
}).catch((err: unknown) => {
|
||||
logger.error("Web vitals ingest failed:", err);
|
||||
});
|
||||
}
|
||||
|
||||
const WebVitalsDashboardContainer = memo(() => {
|
||||
const m = useMessages();
|
||||
const copy = m.webVitalsDashboard;
|
||||
const [vitals, setVitals] = useState<Vitals>(createInitialVitals);
|
||||
const [metrics, setMetrics] = useState<Metrics>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [storage, setStorage] = useState<"external" | "local">("local");
|
||||
|
||||
const rumDashboardUrl =
|
||||
typeof process.env.NEXT_PUBLIC_RUM_DASHBOARD_URL === "string" &&
|
||||
process.env.NEXT_PUBLIC_RUM_DASHBOARD_URL.trim() !== ""
|
||||
? process.env.NEXT_PUBLIC_RUM_DASHBOARD_URL.trim()
|
||||
: null;
|
||||
|
||||
useEffect(() => {
|
||||
const fetchVitals = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/web-vitals");
|
||||
const data = (await response.json()) as { metrics?: Metrics };
|
||||
const data = (await response.json()) as {
|
||||
metrics?: Metrics;
|
||||
storage?: "external" | "local";
|
||||
};
|
||||
setMetrics(data.metrics || {});
|
||||
setStorage(data.storage === "external" ? "external" : "local");
|
||||
} catch (error) {
|
||||
logger.error("Error fetching web vitals:", error);
|
||||
} finally {
|
||||
@@ -39,77 +78,71 @@ const WebVitalsDashboardContainer = memo(() => {
|
||||
fetchVitals();
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
import("web-vitals").then((webVitals) => {
|
||||
// web-vitals v4 typings don't expose legacy get* names the same way; runtime bundle still provides them for this dashboard.
|
||||
const { getCLS, getFID, getFCP, getLCP, getTTFB } =
|
||||
webVitals as unknown as {
|
||||
getCLS: (
|
||||
_fn: (_m: { value: number; rating: string }) => void,
|
||||
) => void;
|
||||
getFID: (
|
||||
_fn: (_m: { value: number; rating: string }) => void,
|
||||
) => void;
|
||||
getFCP: (
|
||||
_fn: (_m: { value: number; rating: string }) => void,
|
||||
) => void;
|
||||
getLCP: (
|
||||
_fn: (_m: { value: number; rating: string }) => void,
|
||||
) => void;
|
||||
getTTFB: (
|
||||
_fn: (_m: { value: number; rating: string }) => void,
|
||||
) => void;
|
||||
};
|
||||
// web-vitals v4+ exposes onLCP / onCLS / … — legacy getLCP was removed.
|
||||
import("web-vitals").then(
|
||||
({ onCLS, onFID, onFCP, onLCP, onTTFB }) => {
|
||||
onLCP((metric) => {
|
||||
const rating = metric.rating as VitalData["rating"];
|
||||
setVitals((prev) => ({
|
||||
...prev,
|
||||
lcp: {
|
||||
value: Math.round(metric.value),
|
||||
rating,
|
||||
},
|
||||
}));
|
||||
reportWebVitalToApi("lcp", Math.round(metric.value), rating);
|
||||
});
|
||||
|
||||
getLCP((metric: { value: number; rating: VitalData["rating"] }) => {
|
||||
setVitals((prev) => ({
|
||||
...prev,
|
||||
lcp: {
|
||||
value: Math.round(metric.value),
|
||||
rating: metric.rating,
|
||||
},
|
||||
}));
|
||||
});
|
||||
onFID((metric) => {
|
||||
const rating = metric.rating as VitalData["rating"];
|
||||
setVitals((prev) => ({
|
||||
...prev,
|
||||
fid: {
|
||||
value: Math.round(metric.value),
|
||||
rating,
|
||||
},
|
||||
}));
|
||||
reportWebVitalToApi("fid", Math.round(metric.value), rating);
|
||||
});
|
||||
|
||||
getFID((metric: { value: number; rating: VitalData["rating"] }) => {
|
||||
setVitals((prev) => ({
|
||||
...prev,
|
||||
fid: {
|
||||
value: Math.round(metric.value),
|
||||
rating: metric.rating,
|
||||
},
|
||||
}));
|
||||
});
|
||||
onCLS((metric) => {
|
||||
const rounded = Math.round(metric.value * 1000) / 1000;
|
||||
const rating = metric.rating as VitalData["rating"];
|
||||
setVitals((prev) => ({
|
||||
...prev,
|
||||
cls: {
|
||||
value: rounded,
|
||||
rating,
|
||||
},
|
||||
}));
|
||||
reportWebVitalToApi("cls", rounded, rating);
|
||||
});
|
||||
|
||||
getCLS((metric: { value: number; rating: VitalData["rating"] }) => {
|
||||
setVitals((prev) => ({
|
||||
...prev,
|
||||
cls: {
|
||||
value: Math.round(metric.value * 1000) / 1000,
|
||||
rating: metric.rating,
|
||||
},
|
||||
}));
|
||||
});
|
||||
onFCP((metric) => {
|
||||
const rating = metric.rating as VitalData["rating"];
|
||||
setVitals((prev) => ({
|
||||
...prev,
|
||||
fcp: {
|
||||
value: Math.round(metric.value),
|
||||
rating,
|
||||
},
|
||||
}));
|
||||
reportWebVitalToApi("fcp", Math.round(metric.value), rating);
|
||||
});
|
||||
|
||||
getFCP((metric: { value: number; rating: VitalData["rating"] }) => {
|
||||
setVitals((prev) => ({
|
||||
...prev,
|
||||
fcp: {
|
||||
value: Math.round(metric.value),
|
||||
rating: metric.rating,
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
||||
getTTFB((metric: { value: number; rating: VitalData["rating"] }) => {
|
||||
setVitals((prev) => ({
|
||||
...prev,
|
||||
ttfb: {
|
||||
value: Math.round(metric.value),
|
||||
rating: metric.rating,
|
||||
},
|
||||
}));
|
||||
});
|
||||
});
|
||||
onTTFB((metric) => {
|
||||
const rating = metric.rating as VitalData["rating"];
|
||||
setVitals((prev) => ({
|
||||
...prev,
|
||||
ttfb: {
|
||||
value: Math.round(metric.value),
|
||||
rating,
|
||||
},
|
||||
}));
|
||||
reportWebVitalToApi("ttfb", Math.round(metric.value), rating);
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -118,6 +151,9 @@ const WebVitalsDashboardContainer = memo(() => {
|
||||
vitals={vitals}
|
||||
metrics={metrics}
|
||||
loading={loading}
|
||||
storage={storage}
|
||||
copy={copy}
|
||||
rumDashboardUrl={rumDashboardUrl}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type messages from "../../../../messages/en/index";
|
||||
|
||||
export interface VitalData {
|
||||
value: number;
|
||||
rating: "good" | "needs-improvement" | "poor" | "unknown";
|
||||
@@ -26,8 +28,13 @@ export interface Metrics {
|
||||
[key: string]: MetricData;
|
||||
}
|
||||
|
||||
export type WebVitalsDashboardCopy = typeof messages.webVitalsDashboard;
|
||||
|
||||
export interface WebVitalsDashboardViewProps {
|
||||
vitals: Vitals;
|
||||
metrics: Metrics;
|
||||
loading: boolean;
|
||||
storage: "external" | "local";
|
||||
copy: WebVitalsDashboardCopy;
|
||||
rumDashboardUrl: string | null;
|
||||
}
|
||||
|
||||
@@ -26,17 +26,20 @@ const getRatingIcon = (rating: string): string => {
|
||||
}
|
||||
};
|
||||
|
||||
const formatValue = (metric: string, value: number): string => {
|
||||
function formatValue(metric: string, value: number): string {
|
||||
if (metric === "cls") {
|
||||
return value.toFixed(3);
|
||||
}
|
||||
return `${value}ms`;
|
||||
};
|
||||
}
|
||||
|
||||
function WebVitalsDashboardView({
|
||||
vitals,
|
||||
metrics,
|
||||
loading,
|
||||
storage,
|
||||
copy,
|
||||
rumDashboardUrl,
|
||||
}: WebVitalsDashboardViewProps) {
|
||||
if (loading) {
|
||||
return (
|
||||
@@ -59,9 +62,31 @@ function WebVitalsDashboardView({
|
||||
return (
|
||||
<div className="p-6 bg-white rounded-lg shadow-lg">
|
||||
<h2 className="text-2xl font-bold mb-6 text-[var(--color-content-default-primary)]">
|
||||
Web Vitals Dashboard
|
||||
{copy.title}
|
||||
</h2>
|
||||
|
||||
{storage === "external" && (
|
||||
<div
|
||||
className="mb-6 p-4 rounded-lg border border-[var(--color-border-default-primary)] bg-[var(--color-surface-default-secondary)] text-[var(--font-size-body-medium)] text-[var(--color-content-default-secondary)]"
|
||||
role="status"
|
||||
>
|
||||
<p className="font-semibold text-[var(--color-content-default-primary)] mb-2">
|
||||
{copy.externalNoticeTitle}
|
||||
</p>
|
||||
<p className="mb-3">{copy.externalNoticeBody}</p>
|
||||
{rumDashboardUrl ? (
|
||||
<a
|
||||
href={rumDashboardUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-[var(--color-content-default-primary)] underline font-medium"
|
||||
>
|
||||
{copy.externalDashboardLinkLabel}
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mb-6">
|
||||
{Object.entries(vitals).map(([metric, data]) => (
|
||||
<div
|
||||
@@ -74,21 +99,20 @@ function WebVitalsDashboardView({
|
||||
</div>
|
||||
<div className="text-sm">
|
||||
<div className="font-medium">
|
||||
Value: {formatValue(metric, data.value)}
|
||||
{copy.valueLabel}: {formatValue(metric, data.value)}
|
||||
</div>
|
||||
<div className="capitalize">
|
||||
Rating: {data.rating.replace("-", " ")}
|
||||
{copy.ratingLabel}: {data.rating.replace("-", " ")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Historical Metrics */}
|
||||
{Object.keys(metrics).length > 0 && (
|
||||
<div className="mb-6">
|
||||
<h3 className="text-lg font-semibold mb-4 text-[var(--color-content-default-primary)]">
|
||||
Historical Metrics
|
||||
{copy.historicalMetricsTitle}
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{Object.entries(metrics).map(([metric, data]) => (
|
||||
@@ -98,20 +122,26 @@ function WebVitalsDashboardView({
|
||||
>
|
||||
<h4 className="font-semibold mb-2">{metric.toUpperCase()}</h4>
|
||||
<div className="text-sm space-y-1">
|
||||
<div>Count: {data.count}</div>
|
||||
<div>Average: {formatValue(metric, data.average)}</div>
|
||||
<div>
|
||||
Range: {formatValue(metric, data.min)} -{" "}
|
||||
{copy.countLabel}: {data.count}
|
||||
</div>
|
||||
<div>
|
||||
{copy.averageLabel}: {formatValue(metric, data.average)}
|
||||
</div>
|
||||
<div>
|
||||
{copy.rangeLabel}: {formatValue(metric, data.min)} -{" "}
|
||||
{formatValue(metric, data.max)}
|
||||
</div>
|
||||
<div className="flex gap-2 text-xs">
|
||||
<span className="text-green-600">
|
||||
Good: {data.goodCount}
|
||||
{copy.goodLabel}: {data.goodCount}
|
||||
</span>
|
||||
<span className="text-yellow-600">
|
||||
Needs Improvement: {data.needsImprovementCount}
|
||||
{copy.needsImprovementLabel}: {data.needsImprovementCount}
|
||||
</span>
|
||||
<span className="text-red-600">
|
||||
{copy.poorLabel}: {data.poorCount}
|
||||
</span>
|
||||
<span className="text-red-600">Poor: {data.poorCount}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -120,32 +150,16 @@ function WebVitalsDashboardView({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Performance Guidelines */}
|
||||
<div className="p-4 bg-[var(--color-surface-default-secondary)] rounded-lg">
|
||||
<h3 className="font-semibold mb-2 text-[var(--color-content-default-primary)]">
|
||||
Performance Guidelines
|
||||
{copy.performanceGuidelinesTitle}
|
||||
</h3>
|
||||
<ul className="text-sm space-y-1 text-[var(--color-content-default-secondary)]">
|
||||
<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>
|
||||
<li>• {copy.guidelines.lcp}</li>
|
||||
<li>• {copy.guidelines.fid}</li>
|
||||
<li>• {copy.guidelines.cls}</li>
|
||||
<li>• {copy.guidelines.fcp}</li>
|
||||
<li>• {copy.guidelines.ttfb}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -10,7 +10,7 @@ Temporary working notes for building the backend. Safe to delete once the stack
|
||||
- **PostgreSQL + Prisma**: schema and migrations under `prisma/`; product APIs under `app/api/*` (health, auth/magic-link, session, drafts, rules, templates, web-vitals).
|
||||
- **Server modules** in `lib/server/` (db, session, mail, rate limiting, etc.).
|
||||
- **Create flow:** **Anonymous** users mirror in-progress state to **`create-flow-anonymous`** in `localStorage`; **Exit** opens the save-progress magic-link modal; after verify, [`PostLoginDraftTransfer`](app/(app)/create/PostLoginDraftTransfer.tsx) can **PUT** `/api/drafts/me` when **`NEXT_PUBLIC_ENABLE_BACKEND_SYNC=true`**. **Signed-in** users get a **fresh** in-memory session per “Create rule” entry, but with sync on the layout may **hydrate** from **`GET /api/drafts/me`** via [`SignedInDraftHydration`](app/(app)/create/SignedInDraftHydration.tsx); **Save & Exit** (from `community-structure` onward) **PUT**s when sync is on. **Log in** from the marketing header uses the global modal ([`AuthModalProvider`](app/contexts/AuthModalContext.tsx)); **`/login`** remains for verify errors and deep links. **Step order and URLs:** [`docs/create-flow.md`](docs/create-flow.md) and [`app/(app)/create/utils/flowSteps.ts`](app/(app)/create/utils/flowSteps.ts).
|
||||
- **Web vitals** [`app/api/web-vitals/route.ts`](app/api/web-vitals/route.ts) still use **file-based** storage under `.next` (not suitable for multi-instance production).
|
||||
- **Web vitals** [`app/api/web-vitals/route.ts`](app/api/web-vitals/route.ts): **production default** is **`external`** (structured logs; no `.next` writes). **`local`** file-based mode remains for development (`WEB_VITALS_STORAGE`).
|
||||
- **CI:** [`.gitea/workflows/ci.yaml`](.gitea/workflows/ci.yaml) (build, test, lint, `prisma validate`); no in-repo production deploy definition.
|
||||
|
||||
### HTTP API (implemented in repo)
|
||||
@@ -28,7 +28,7 @@ Mirrors [CONTRIBUTING.md](../CONTRIBUTING.md) **API routes** table (including `/
|
||||
| GET / POST | `/api/rules` | List or publish rules |
|
||||
| GET | `/api/templates` | List curated templates; optional `facet.*` re-ranks (see [template-recommendation-matrix.md](template-recommendation-matrix.md)) |
|
||||
| GET | `/api/create-flow/methods` | Facet scores for wizard method lists (`section` + optional `facet.*`) |
|
||||
| POST / GET | `/api/web-vitals` | Web vitals ingest / aggregate (file-based under `.next` today; production path §7) |
|
||||
| POST / GET | `/api/web-vitals` | Web vitals ingest / read aggregates (`external` default in production — logs only; `local` under `.next` in dev — see §7) |
|
||||
|
||||
**Product sign-in** uses **magic link** (`/api/auth/magic-link/*`).
|
||||
|
||||
@@ -136,7 +136,7 @@ Match the current API behavior; tighten as product evolves:
|
||||
|
||||
**Operator / local (always manual):** Steps 1–4 — env file, Docker Postgres, `npm ci`, `prisma migrate dev`, `npm run dev`.
|
||||
|
||||
**Backend behavior already in the repo:** Steps **5–10** match implemented Route Handlers and middleware (`lib/server/*`). **Step 11** (web vitals) is **not** production-ready (files under `.next`); treat as follow-up work aligned with §7.
|
||||
**Backend behavior already in the repo:** Steps **5–10** match implemented Route Handlers and middleware (`lib/server/*`). **Step 11** (web vitals): production defaults to **`external`** (no `.next` writes); optional vendor RUM or DB persistence remains a deliberate ops choice per §7.
|
||||
|
||||
**Product / frontend still open (not only “backend exists”):** Sign-in UI, wiring publish from the create flow, template seed + UI consumption (flat list first), **canon create-flow alignment** (Ticket 17 / [CR-89](https://linear.app/community-rule/issue/CR-89/product-canon-custom-create-rule-wizard-routes-resume-progress-repo) — progress bar, resume URL, `[step]` cleanup; spec in [`docs/create-flow.md`](create-flow.md)), **spreadsheet-driven template recommendations** (Ticket 16 / [CR-88](https://linear.app/community-rule/issue/CR-88/backend-template-recommendation-matrix-xlsx-sheets-ingestion) — after v1 templates), **profile / my rules dashboard** (Ticket 15)—see §12 and [docs/backend-linear-tickets.md](backend-linear-tickets.md).
|
||||
|
||||
@@ -182,7 +182,7 @@ npm run dev
|
||||
|
||||
**Step 10.** **Frontend draft sync:** Set `NEXT_PUBLIC_ENABLE_BACKEND_SYNC=true` in `.env` so **Save & Exit** and **post-login anonymous → account transfer** can **PUT** `/api/drafts/me`. Without sync, drafts are **not** written to the server (anonymous progress still lives in `localStorage` only).
|
||||
|
||||
**Step 11.** **Web vitals:** Move off `.next` files—**prefer an external analytics or logging pipeline** (see §7). Use Postgres for vitals only as a deliberate ops choice.
|
||||
**Step 11.** **Web vitals:** Production uses **`external`** storage (structured logs). Add a browser RUM SDK or Postgres-backed vitals only as a deliberate ops choice (see §7).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { z } from "zod";
|
||||
|
||||
/** POST /api/web-vitals — browser ingest payload */
|
||||
export const webVitalIngestSchema = z.object({
|
||||
metric: z.string().min(1).max(64),
|
||||
data: z.object({
|
||||
value: z.number().finite(),
|
||||
rating: z.string().min(1).max(32),
|
||||
}),
|
||||
url: z.string().min(1).max(8192),
|
||||
userAgent: z.string().max(512).optional().default(""),
|
||||
timestamp: z.union([z.string(), z.number()]),
|
||||
});
|
||||
|
||||
export type WebVitalIngestInput = z.infer<typeof webVitalIngestSchema>;
|
||||
@@ -0,0 +1,115 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { logger } from "../../logger";
|
||||
|
||||
const WEB_VITALS_DIR = path.join(process.cwd(), ".next", "web-vitals");
|
||||
|
||||
export interface WebVitalData {
|
||||
metric: string;
|
||||
data: {
|
||||
value: number;
|
||||
rating: string;
|
||||
};
|
||||
url: string;
|
||||
userAgent: string;
|
||||
timestamp: string;
|
||||
receivedAt: string;
|
||||
}
|
||||
|
||||
export interface WebVitalMetrics {
|
||||
[metric: string]: {
|
||||
count: number;
|
||||
average: number;
|
||||
min: number;
|
||||
max: number;
|
||||
goodCount: number;
|
||||
needsImprovementCount: number;
|
||||
poorCount: number;
|
||||
lastUpdated: string;
|
||||
};
|
||||
}
|
||||
|
||||
function ensureWebVitalsDir(): void {
|
||||
if (!fs.existsSync(WEB_VITALS_DIR)) {
|
||||
fs.mkdirSync(WEB_VITALS_DIR, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
export function appendLocalWebVital(vitalsData: WebVitalData): void {
|
||||
ensureWebVitalsDir();
|
||||
const filePath = path.join(WEB_VITALS_DIR, `${vitalsData.metric}.json`);
|
||||
let existingData: WebVitalData[] = [];
|
||||
|
||||
if (fs.existsSync(filePath)) {
|
||||
try {
|
||||
const fileContent = fs.readFileSync(filePath, "utf8");
|
||||
existingData = JSON.parse(fileContent) as WebVitalData[];
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
logger.warn("Could not parse existing vitals data:", err.message);
|
||||
}
|
||||
}
|
||||
|
||||
existingData.push(vitalsData);
|
||||
|
||||
if (existingData.length > 100) {
|
||||
existingData = existingData.slice(-100);
|
||||
}
|
||||
|
||||
fs.writeFileSync(filePath, JSON.stringify(existingData, null, 2));
|
||||
}
|
||||
|
||||
export function readLocalAggregatedMetrics(): WebVitalMetrics {
|
||||
const metrics: WebVitalMetrics = {};
|
||||
|
||||
if (!fs.existsSync(WEB_VITALS_DIR)) {
|
||||
return metrics;
|
||||
}
|
||||
|
||||
const files = fs.readdirSync(WEB_VITALS_DIR);
|
||||
|
||||
files.forEach((file) => {
|
||||
if (!file.endsWith(".json")) return;
|
||||
const metric = file.replace(".json", "");
|
||||
let data: WebVitalData[];
|
||||
try {
|
||||
const fileContent = fs.readFileSync(
|
||||
path.join(WEB_VITALS_DIR, file),
|
||||
"utf8",
|
||||
);
|
||||
data = JSON.parse(fileContent) as WebVitalData[];
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
`Skipping corrupt web vitals file ${file}:`,
|
||||
(error as Error).message,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.length === 0) return;
|
||||
|
||||
const values = data
|
||||
.map((d) => d.data.value)
|
||||
.filter((v) => v !== undefined);
|
||||
const ratings = data
|
||||
.map((d) => d.data.rating)
|
||||
.filter((r) => r !== undefined);
|
||||
|
||||
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,
|
||||
lastUpdated: data[data.length - 1]?.receivedAt || "",
|
||||
};
|
||||
});
|
||||
|
||||
return metrics;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Web vitals persistence mode (CR-80). Default: external in production, local in dev.
|
||||
* Does not require a specific observability vendor — ops can pipe structured logs to any stack.
|
||||
*/
|
||||
export type WebVitalsStorageMode = "external" | "local";
|
||||
|
||||
const VALID: WebVitalsStorageMode[] = ["external", "local"];
|
||||
|
||||
function normalizeEnv(
|
||||
raw: string | undefined,
|
||||
): WebVitalsStorageMode | undefined {
|
||||
const v = raw?.trim().toLowerCase();
|
||||
if (v === "external" || v === "local") return v;
|
||||
if (v === "database") {
|
||||
// Reserved for Ticket 9 option C — not implemented yet; safe default.
|
||||
return "external";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves storage mode from `WEB_VITALS_STORAGE` or defaults:
|
||||
* - `production` → `external` (no `.next` writes; ingest logs only)
|
||||
* - otherwise → `local` (file-based under `.next/web-vitals` for dev/admin)
|
||||
*/
|
||||
export function getWebVitalsStorageMode(): WebVitalsStorageMode {
|
||||
const fromEnv = normalizeEnv(process.env.WEB_VITALS_STORAGE);
|
||||
if (fromEnv && VALID.includes(fromEnv)) return fromEnv;
|
||||
|
||||
return process.env.NODE_ENV === "production" ? "external" : "local";
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"_comment": "Admin Web Vitals dashboard copy",
|
||||
"title": "Web Vitals Dashboard",
|
||||
"historicalMetricsTitle": "Historical Metrics",
|
||||
"performanceGuidelinesTitle": "Performance Guidelines",
|
||||
"valueLabel": "Value",
|
||||
"ratingLabel": "Rating",
|
||||
"countLabel": "Count",
|
||||
"averageLabel": "Average",
|
||||
"rangeLabel": "Range",
|
||||
"goodLabel": "Good",
|
||||
"needsImprovementLabel": "Needs Improvement",
|
||||
"poorLabel": "Poor",
|
||||
"externalNoticeTitle": "Server-side aggregates",
|
||||
"externalNoticeBody": "Production uses external storage for web vitals. Historical totals are not kept in this app; use your log pipeline or metrics dashboard. Live values below still reflect this browser session.",
|
||||
"externalDashboardLinkLabel": "Open metrics dashboard",
|
||||
"guidelines": {
|
||||
"lcp": "LCP: Good < 2.5s, Needs Improvement 2.5–4s, Poor > 4s",
|
||||
"fid": "FID: Good < 100ms, Needs Improvement 100–300ms, Poor > 300ms",
|
||||
"cls": "CLS: Good < 0.1, Needs Improvement 0.1–0.25, Poor > 0.25",
|
||||
"fcp": "FCP: Good < 1.8s, Needs Improvement 1.8–3s, Poor > 3s",
|
||||
"ttfb": "TTFB: Good < 800ms, Needs Improvement 800–1800ms, Poor > 1800ms"
|
||||
}
|
||||
}
|
||||
@@ -11,9 +11,11 @@ import menuBar from "./components/menuBar.json";
|
||||
import quoteBlock from "./components/quoteBlock.json";
|
||||
import ruleCard from "./components/ruleCard.json";
|
||||
import ruleStack from "./components/ruleStack.json";
|
||||
import webVitalsDashboard from "./components/webVitalsDashboard.json";
|
||||
import home from "./pages/home.json";
|
||||
import templates from "./pages/templates.json";
|
||||
import learn from "./pages/learn.json";
|
||||
import monitor from "./pages/monitor.json";
|
||||
import login from "./pages/login.json";
|
||||
import profile from "./pages/profile.json";
|
||||
import navigation from "./navigation.json";
|
||||
@@ -62,10 +64,12 @@ export default {
|
||||
quoteBlock,
|
||||
ruleCard,
|
||||
ruleStack,
|
||||
webVitalsDashboard,
|
||||
pages: {
|
||||
home,
|
||||
templates,
|
||||
learn,
|
||||
monitor,
|
||||
login,
|
||||
profile,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"_comment": "Admin /monitor performance page",
|
||||
"title": "Performance Monitoring",
|
||||
"description": "Real-time monitoring of Core Web Vitals and performance metrics for Community Rule 3.0",
|
||||
"performanceTargets": {
|
||||
"title": "Performance Targets",
|
||||
"loadTime": "Load Time",
|
||||
"loadTimeTarget": "< 2 seconds",
|
||||
"lcp": "LCP",
|
||||
"lcpTarget": "< 2.5s",
|
||||
"fid": "FID",
|
||||
"fidTarget": "< 100ms",
|
||||
"cls": "CLS",
|
||||
"clsTarget": "< 0.1",
|
||||
"lighthouse": "Lighthouse Score",
|
||||
"lighthouseTarget": "> 90"
|
||||
},
|
||||
"optimizationStatus": {
|
||||
"title": "Optimization Status",
|
||||
"codeSplitting": "Code Splitting & Dynamic Imports",
|
||||
"reactMemo": "React.memo Optimizations",
|
||||
"imageOptimization": "Image Optimization",
|
||||
"fontPreloading": "Font Preloading",
|
||||
"bundleAnalysis": "Bundle Analysis",
|
||||
"errorBoundaries": "Error Boundaries"
|
||||
},
|
||||
"monitoringCommands": {
|
||||
"title": "Monitoring Commands",
|
||||
"bundleAnalyze": {
|
||||
"title": "Bundle analysis",
|
||||
"command": "npm run bundle:analyze"
|
||||
},
|
||||
"e2ePerformance": {
|
||||
"title": "E2E performance (Core Web Vitals)",
|
||||
"command": "npm run e2e:performance"
|
||||
},
|
||||
"lhciDesktop": {
|
||||
"title": "Lighthouse CI (desktop preset)",
|
||||
"command": "npm run lhci:desktop"
|
||||
},
|
||||
"performanceBudget": {
|
||||
"title": "Lighthouse CI with performance budgets",
|
||||
"command": "npm run performance:budget"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { getWebVitalsStorageMode } from "../../lib/server/webVitals/mode";
|
||||
|
||||
describe("getWebVitalsStorageMode", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it("returns external when WEB_VITALS_STORAGE=external", () => {
|
||||
vi.stubEnv("WEB_VITALS_STORAGE", "external");
|
||||
vi.stubEnv("NODE_ENV", "development");
|
||||
expect(getWebVitalsStorageMode()).toBe("external");
|
||||
});
|
||||
|
||||
it("returns local when WEB_VITALS_STORAGE=local", () => {
|
||||
vi.stubEnv("WEB_VITALS_STORAGE", "local");
|
||||
vi.stubEnv("NODE_ENV", "production");
|
||||
expect(getWebVitalsStorageMode()).toBe("local");
|
||||
});
|
||||
|
||||
it("defaults to external in production when unset", () => {
|
||||
vi.stubEnv("NODE_ENV", "production");
|
||||
expect(getWebVitalsStorageMode()).toBe("external");
|
||||
});
|
||||
|
||||
it("defaults to local in development when unset", () => {
|
||||
vi.stubEnv("NODE_ENV", "development");
|
||||
expect(getWebVitalsStorageMode()).toBe("local");
|
||||
});
|
||||
|
||||
it("maps database to external until implemented", () => {
|
||||
vi.stubEnv("WEB_VITALS_STORAGE", "database");
|
||||
vi.stubEnv("NODE_ENV", "development");
|
||||
expect(getWebVitalsStorageMode()).toBe("external");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { webVitalIngestSchema } from "../../lib/server/validation/webVitalsSchema";
|
||||
|
||||
describe("webVitalIngestSchema", () => {
|
||||
it("accepts a valid payload", () => {
|
||||
const parsed = webVitalIngestSchema.safeParse({
|
||||
metric: "lcp",
|
||||
data: { value: 1200, rating: "good" },
|
||||
url: "https://example.com/path",
|
||||
userAgent: "jest",
|
||||
timestamp: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
expect(parsed.success).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts numeric timestamp", () => {
|
||||
const parsed = webVitalIngestSchema.safeParse({
|
||||
metric: "cls",
|
||||
data: { value: 0.05, rating: "good" },
|
||||
url: "https://example.com/",
|
||||
timestamp: 1_700_000_000_000,
|
||||
});
|
||||
expect(parsed.success).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects empty metric", () => {
|
||||
const parsed = webVitalIngestSchema.safeParse({
|
||||
metric: "",
|
||||
data: { value: 1, rating: "good" },
|
||||
url: "https://example.com/",
|
||||
timestamp: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
expect(parsed.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects empty url", () => {
|
||||
const parsed = webVitalIngestSchema.safeParse({
|
||||
metric: "lcp",
|
||||
data: { value: 1, rating: "good" },
|
||||
url: "",
|
||||
timestamp: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
expect(parsed.success).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user