Web vitals: prefer external RUM

This commit is contained in:
adilallo
2026-04-21 07:08:31 -06:00
parent aaa3e4d654
commit 2d58887a15
17 changed files with 713 additions and 372 deletions
+7
View File
@@ -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
View File
@@ -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
+168
View File
@@ -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>
);
}
+2 -156
View File
@@ -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">
&lt; 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">
&lt; 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">
&lt; 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">&lt; 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">&gt; 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
View File
@@ -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 &lt; 2.5s, Needs Improvement 2.5-4s,
Poor &gt; 4s
</li>
<li>
<strong>FID:</strong> Good &lt; 100ms, Needs Improvement
100-300ms, Poor &gt; 300ms
</li>
<li>
<strong>CLS:</strong> Good &lt; 0.1, Needs Improvement 0.1-0.25,
Poor &gt; 0.25
</li>
<li>
<strong>FCP:</strong> Good &lt; 1.8s, Needs Improvement 1.8-3s,
Poor &gt; 3s
</li>
<li>
<strong>TTFB:</strong> Good &lt; 800ms, Needs Improvement
800-1800ms, Poor &gt; 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>
+4 -4
View File
@@ -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 14 — env file, Docker Postgres, `npm ci`, `prisma migrate dev`, `npm run dev`.
**Backend behavior already in the repo:** Steps **510** 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 **510** 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).
---
+15
View File
@@ -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>;
+115
View File
@@ -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;
}
+31
View File
@@ -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.54s, Poor > 4s",
"fid": "FID: Good < 100ms, Needs Improvement 100300ms, Poor > 300ms",
"cls": "CLS: Good < 0.1, Needs Improvement 0.10.25, Poor > 0.25",
"fcp": "FCP: Good < 1.8s, Needs Improvement 1.83s, Poor > 3s",
"ttfb": "TTFB: Good < 800ms, Needs Improvement 8001800ms, Poor > 1800ms"
}
}
+4
View File
@@ -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,
},
+46
View File
@@ -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"
}
}
}
+36
View File
@@ -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");
});
});
+45
View File
@@ -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);
});
});