From 2d58887a15aa9a54b9748c2464bc5b9ae73cf3c8 Mon Sep 17 00:00:00 2001 From: adilallo <39313955+adilallo@users.noreply.github.com> Date: Tue, 21 Apr 2026 07:08:31 -0600 Subject: [PATCH] Web vitals: prefer external RUM --- .env.example | 7 + CONTRIBUTING.md | 2 +- app/(admin)/monitor/MonitorPageContent.tsx | 168 +++++++++++++++++ app/(admin)/monitor/page.tsx | 158 +--------------- app/api/web-vitals/route.ts | 163 ++++++----------- .../WebVitalsDashboard.container.tsx | 172 +++++++++++------- .../WebVitalsDashboard.types.ts | 7 + .../WebVitalsDashboard.view.tsx | 84 +++++---- docs/guides/backend-roadmap.md | 8 +- lib/server/validation/webVitalsSchema.ts | 15 ++ lib/server/webVitals/localFileStore.ts | 115 ++++++++++++ lib/server/webVitals/mode.ts | 31 ++++ .../en/components/webVitalsDashboard.json | 24 +++ messages/en/index.ts | 4 + messages/en/pages/monitor.json | 46 +++++ tests/unit/webVitalsMode.test.ts | 36 ++++ tests/unit/webVitalsSchema.test.ts | 45 +++++ 17 files changed, 713 insertions(+), 372 deletions(-) create mode 100644 app/(admin)/monitor/MonitorPageContent.tsx create mode 100644 lib/server/validation/webVitalsSchema.ts create mode 100644 lib/server/webVitals/localFileStore.ts create mode 100644 lib/server/webVitals/mode.ts create mode 100644 messages/en/components/webVitalsDashboard.json create mode 100644 messages/en/pages/monitor.json create mode 100644 tests/unit/webVitalsMode.test.ts create mode 100644 tests/unit/webVitalsSchema.test.ts diff --git a/.env.example b/.env.example index 1a818e9..4e6ab5a 100644 --- a/.env.example +++ b/.env.example @@ -13,3 +13,10 @@ SMTP_FROM="Community Rule " # 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= diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3231c04..0e46bd5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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.=` 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 diff --git a/app/(admin)/monitor/MonitorPageContent.tsx b/app/(admin)/monitor/MonitorPageContent.tsx new file mode 100644 index 0000000..dd839f9 --- /dev/null +++ b/app/(admin)/monitor/MonitorPageContent.tsx @@ -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 ( +
+ + +
+
+
+

+ {p.title} +

+

+ {p.description} +

+
+ +
+
+

+ {p.performanceTargets.title} +

+
+
+ + {p.performanceTargets.loadTime} + + + {p.performanceTargets.loadTimeTarget} + +
+
+ + {p.performanceTargets.lcp} + + + {p.performanceTargets.lcpTarget} + +
+
+ + {p.performanceTargets.fid} + + + {p.performanceTargets.fidTarget} + +
+
+ + {p.performanceTargets.cls} + + + {p.performanceTargets.clsTarget} + +
+
+ + {p.performanceTargets.lighthouse} + + + {p.performanceTargets.lighthouseTarget} + +
+
+
+ +
+

+ {p.optimizationStatus.title} +

+
+
+ + + {p.optimizationStatus.codeSplitting} + +
+
+ + + {p.optimizationStatus.reactMemo} + +
+
+ + + {p.optimizationStatus.imageOptimization} + +
+
+ + + {p.optimizationStatus.fontPreloading} + +
+
+ + + {p.optimizationStatus.bundleAnalysis} + +
+
+ + + {p.optimizationStatus.errorBoundaries} + +
+
+
+
+ + + +
+

+ {p.monitoringCommands.title} +

+
+
+

+ {p.monitoringCommands.bundleAnalyze.title} +

+ + {p.monitoringCommands.bundleAnalyze.command} + +
+
+

+ {p.monitoringCommands.e2ePerformance.title} +

+ + {p.monitoringCommands.e2ePerformance.command} + +
+
+

+ {p.monitoringCommands.lhciDesktop.title} +

+ + {p.monitoringCommands.lhciDesktop.command} + +
+
+

+ {p.monitoringCommands.performanceBudget.title} +

+ + {p.monitoringCommands.performanceBudget.command} + +
+
+
+
+
+ +
+
+ ); +} diff --git a/app/(admin)/monitor/page.tsx b/app/(admin)/monitor/page.tsx index 9ba8ff2..fe924de 100644 --- a/app/(admin)/monitor/page.tsx +++ b/app/(admin)/monitor/page.tsx @@ -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 ( -
- - -
-
-
-

- Performance Monitoring -

-

- Real-time monitoring of Core Web Vitals and performance metrics - for Community Rule 3.0 -

-
- -
-
-

- Performance Targets -

-
-
- - Load Time - - - < 2 seconds - -
-
- - LCP - - - < 2.5s - -
-
- - FID - - - < 100ms - -
-
- - CLS - - < 0.1 -
-
- - Lighthouse Score - - > 90 -
-
-
- -
-

- Optimization Status -

-
-
- - - Code Splitting & Dynamic Imports - -
-
- - - React.memo Optimizations - -
-
- - - Image Optimization - -
-
- - - Font Preloading - -
-
- - - Bundle Analysis - -
-
- - - Error Boundaries - -
-
-
-
- - - -
-

- Monitoring Commands -

-
-
-

- Bundle Analysis -

- - npm run bundle:analyze - -
-
-

- Performance Monitor -

- - npm run performance:monitor - -
-
-

- Web Vitals Tracking -

- - npm run web-vitals:track - -
-
-

- All Monitoring -

- - npm run monitor:all - -
-
-
-
-
- -
-
- ); + return ; } diff --git a/app/api/web-vitals/route.ts b/app/api/web-vitals/route.ts index a1beafe..ea2b1df 100644 --- a/app/api/web-vitals/route.ts +++ b/app/api/web-vitals/route.ts @@ -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( diff --git a/app/components/sections/WebVitalsDashboard/WebVitalsDashboard.container.tsx b/app/components/sections/WebVitalsDashboard/WebVitalsDashboard.container.tsx index 8f2d7ce..05767a1 100644 --- a/app/components/sections/WebVitalsDashboard/WebVitalsDashboard.container.tsx +++ b/app/components/sections/WebVitalsDashboard/WebVitalsDashboard.container.tsx @@ -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(createInitialVitals); const [metrics, setMetrics] = useState({}); 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} /> ); }); diff --git a/app/components/sections/WebVitalsDashboard/WebVitalsDashboard.types.ts b/app/components/sections/WebVitalsDashboard/WebVitalsDashboard.types.ts index 945bb7f..bb6deeb 100644 --- a/app/components/sections/WebVitalsDashboard/WebVitalsDashboard.types.ts +++ b/app/components/sections/WebVitalsDashboard/WebVitalsDashboard.types.ts @@ -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; } diff --git a/app/components/sections/WebVitalsDashboard/WebVitalsDashboard.view.tsx b/app/components/sections/WebVitalsDashboard/WebVitalsDashboard.view.tsx index ec1d073..593a915 100644 --- a/app/components/sections/WebVitalsDashboard/WebVitalsDashboard.view.tsx +++ b/app/components/sections/WebVitalsDashboard/WebVitalsDashboard.view.tsx @@ -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 (

- Web Vitals Dashboard + {copy.title}

+ {storage === "external" && ( +
+

+ {copy.externalNoticeTitle} +

+

{copy.externalNoticeBody}

+ {rumDashboardUrl ? ( + + {copy.externalDashboardLinkLabel} + + ) : null} +
+ )} +
{Object.entries(vitals).map(([metric, data]) => (
- Value: {formatValue(metric, data.value)} + {copy.valueLabel}: {formatValue(metric, data.value)}
- Rating: {data.rating.replace("-", " ")} + {copy.ratingLabel}: {data.rating.replace("-", " ")}
))}
- {/* Historical Metrics */} {Object.keys(metrics).length > 0 && (

- Historical Metrics + {copy.historicalMetricsTitle}

{Object.entries(metrics).map(([metric, data]) => ( @@ -98,20 +122,26 @@ function WebVitalsDashboardView({ >

{metric.toUpperCase()}

-
Count: {data.count}
-
Average: {formatValue(metric, data.average)}
- Range: {formatValue(metric, data.min)} -{" "} + {copy.countLabel}: {data.count} +
+
+ {copy.averageLabel}: {formatValue(metric, data.average)} +
+
+ {copy.rangeLabel}: {formatValue(metric, data.min)} -{" "} {formatValue(metric, data.max)}
- Good: {data.goodCount} + {copy.goodLabel}: {data.goodCount} - Needs Improvement: {data.needsImprovementCount} + {copy.needsImprovementLabel}: {data.needsImprovementCount} + + + {copy.poorLabel}: {data.poorCount} - Poor: {data.poorCount}
@@ -120,32 +150,16 @@ function WebVitalsDashboardView({
)} - {/* Performance Guidelines */}

- Performance Guidelines + {copy.performanceGuidelinesTitle}

    -
  • - • LCP: Good < 2.5s, Needs Improvement 2.5-4s, - Poor > 4s -
  • -
  • - • FID: Good < 100ms, Needs Improvement - 100-300ms, Poor > 300ms -
  • -
  • - • CLS: Good < 0.1, Needs Improvement 0.1-0.25, - Poor > 0.25 -
  • -
  • - • FCP: Good < 1.8s, Needs Improvement 1.8-3s, - Poor > 3s -
  • -
  • - • TTFB: Good < 800ms, Needs Improvement - 800-1800ms, Poor > 1800ms -
  • +
  • • {copy.guidelines.lcp}
  • +
  • • {copy.guidelines.fid}
  • +
  • • {copy.guidelines.cls}
  • +
  • • {copy.guidelines.fcp}
  • +
  • • {copy.guidelines.ttfb}
diff --git a/docs/guides/backend-roadmap.md b/docs/guides/backend-roadmap.md index 3c3ec32..d5fb602 100644 --- a/docs/guides/backend-roadmap.md +++ b/docs/guides/backend-roadmap.md @@ -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). --- diff --git a/lib/server/validation/webVitalsSchema.ts b/lib/server/validation/webVitalsSchema.ts new file mode 100644 index 0000000..34a4f11 --- /dev/null +++ b/lib/server/validation/webVitalsSchema.ts @@ -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; diff --git a/lib/server/webVitals/localFileStore.ts b/lib/server/webVitals/localFileStore.ts new file mode 100644 index 0000000..26352f2 --- /dev/null +++ b/lib/server/webVitals/localFileStore.ts @@ -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; +} diff --git a/lib/server/webVitals/mode.ts b/lib/server/webVitals/mode.ts new file mode 100644 index 0000000..1ab50be --- /dev/null +++ b/lib/server/webVitals/mode.ts @@ -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"; +} diff --git a/messages/en/components/webVitalsDashboard.json b/messages/en/components/webVitalsDashboard.json new file mode 100644 index 0000000..d93b589 --- /dev/null +++ b/messages/en/components/webVitalsDashboard.json @@ -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" + } +} diff --git a/messages/en/index.ts b/messages/en/index.ts index 2a44d79..0c54a8b 100644 --- a/messages/en/index.ts +++ b/messages/en/index.ts @@ -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, }, diff --git a/messages/en/pages/monitor.json b/messages/en/pages/monitor.json new file mode 100644 index 0000000..5508bb6 --- /dev/null +++ b/messages/en/pages/monitor.json @@ -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" + } + } +} diff --git a/tests/unit/webVitalsMode.test.ts b/tests/unit/webVitalsMode.test.ts new file mode 100644 index 0000000..656a4d5 --- /dev/null +++ b/tests/unit/webVitalsMode.test.ts @@ -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"); + }); +}); diff --git a/tests/unit/webVitalsSchema.test.ts b/tests/unit/webVitalsSchema.test.ts new file mode 100644 index 0000000..f385e46 --- /dev/null +++ b/tests/unit/webVitalsSchema.test.ts @@ -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); + }); +});