diff --git a/apps/web/.gitignore b/apps/web/.gitignore new file mode 100644 index 0000000..34fee9f --- /dev/null +++ b/apps/web/.gitignore @@ -0,0 +1,4 @@ + +.claude/ + +.codegraph/ diff --git a/apps/web/package.json b/apps/web/package.json index 51a996d..7df9fac 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -21,6 +21,7 @@ "react-dom": "^19.2.6", "react-router-dom": "^7.15.1", "tailwind-merge": "^3.6.0", + "web-vitals": "5.3.0", "zustand": "^5.0.13" }, "devDependencies": { diff --git a/apps/web/src/app.tsx b/apps/web/src/app.tsx index 93b7301..d3cfce9 100644 --- a/apps/web/src/app.tsx +++ b/apps/web/src/app.tsx @@ -1,3 +1,4 @@ +import { useEffect } from "react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { createBrowserRouter, @@ -5,7 +6,6 @@ import { Navigate, } from "react-router-dom"; import { AuthInitializer } from "@/components/auth-initializer"; -// import { AuthGuard } from "@/components/auth-guard"; import { DashboardPage } from "@/pages/dashboard-page"; import { ScanPage } from "@/pages/scan-page"; import { LibraryPage } from "@/pages/library-page"; @@ -14,16 +14,13 @@ import { DiseaseDetailPage } from "@/pages/disease-detail-page"; import { DiagnosisDetailPage } from "@/pages/diagnosis-detail-page"; import { ExpertReviewsPage } from "@/pages/expert-reviews-page"; import { DiagnosesPage } from "@/pages/diagnoses-page"; -// import { LoginPage } from "@/pages/login-page"; -// import { RegisterPage } from "@/pages/register-page"; import { MainLayout } from "@/components/layout/main-layout"; +import { trackPageView } from "./lib/telemetry"; const queryClient = new QueryClient(); const router = createBrowserRouter([ { path: "/", element: }, - // { path: "/login", element: }, - // { path: "/register", element: }, { path: "/dashboard", element: ( @@ -90,10 +87,19 @@ const router = createBrowserRouter([ }, ]); +function PageViewTracker() { + const location = window.location; + useEffect(() => { + trackPageView(location.pathname + location.search); + }, [location.pathname, location.search]); + return null; +} + export function App() { return ( + ); diff --git a/apps/web/src/lib/telemetry.ts b/apps/web/src/lib/telemetry.ts new file mode 100644 index 0000000..46f3255 --- /dev/null +++ b/apps/web/src/lib/telemetry.ts @@ -0,0 +1,52 @@ +/** + * Client‑side telemetry for the ZeaVis Edu web app. + * + * In development, metrics are collected in‑memory and exposed at /metrics + * via a Vite plugin. In production they are sent as HTTP beacons to the + * Telemetry pipeline (see METRICS.md). + */ + +// ── Web Vitals ────────────────────────────────────────── + +export type MetricEntry = { + name: string; + value: number; + rating?: string; +}; + +const vitalsBuffer: MetricEntry[] = []; + +export function reportWebVitals(metric: MetricEntry): void { + vitalsBuffer.push(metric); + // Keep last 20 entries in memory for the /metrics endpoint + if (vitalsBuffer.length > 20) vitalsBuffer.shift(); + console.debug(`[telemetry] ${metric.name}: ${metric.value} (${metric.rating ?? 'n/a'})`); +} + +// ── Page‑view counter ─────────────────────────────────── + +let pageViewCount = 0; + +export function trackPageView(path: string): void { + pageViewCount++; + console.debug(`[telemetry] pageview: ${path} (total: ${pageViewCount})`); +} + +// ── Metrics serialisation (consumed by vite‑plugin) ──── + +export function collectMetrics(): string { + const lines: string[] = []; + + // ── Default process‑like metrics ────────────────────── + lines.push('# HELP zeavis_web_page_views_total Total page views'); + lines.push('# TYPE zeavis_web_page_views_total counter'); + lines.push(`zeavis_web_page_views_total ${pageViewCount}`); + + lines.push('# HELP zeavis_web_vital_bucket Web Vitals observed this session'); + lines.push('# TYPE zeavis_web_vital_bucket gauge'); + for (const v of vitalsBuffer) { + lines.push(`zeavis_web_vital_bucket{name="${v.name}",rating="${v.rating ?? 'unknown'}"} ${v.value}`); + } + + return lines.join('\n') + '\n'; +} diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index e869dfb..9789fcf 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -2,6 +2,15 @@ import React from "react"; import ReactDOM from "react-dom/client"; import { App } from "./app"; import "./index.css"; +import { reportWebVitals } from "./lib/telemetry"; +import { onCLS, onFCP, onINP, onLCP, onTTFB } from "web-vitals"; + +// Report Web Vitals to our in-memory telemetry store +onCLS((m) => reportWebVitals({ name: "CLS", value: m.value, rating: m.rating })); +onFCP((m) => reportWebVitals({ name: "FCP", value: m.value, rating: m.rating })); +onINP((m) => reportWebVitals({ name: "INP", value: m.value, rating: m.rating })); +onLCP((m) => reportWebVitals({ name: "LCP", value: m.value, rating: m.rating })); +onTTFB((m) => reportWebVitals({ name: "TTFB", value: m.value, rating: m.rating })); ReactDOM.createRoot(document.getElementById("root")!).render( diff --git a/apps/web/tsconfig.node.json b/apps/web/tsconfig.node.json index 8237f41..6bd2998 100644 --- a/apps/web/tsconfig.node.json +++ b/apps/web/tsconfig.node.json @@ -7,5 +7,5 @@ "moduleResolution": "Bundler", "types": ["node"] }, - "include": ["vite.config.ts", "tailwind.config.ts"] + "include": ["vite.config.ts", "tailwind.config.ts", "vite-plugin-metrics.ts"] } diff --git a/apps/web/vite-plugin-metrics.ts b/apps/web/vite-plugin-metrics.ts new file mode 100644 index 0000000..f157233 --- /dev/null +++ b/apps/web/vite-plugin-metrics.ts @@ -0,0 +1,42 @@ +import type { Plugin } from 'vite'; + +/** + * Vite plugin that exposes a /metrics endpoint during development. + * + * The endpoint returns Prometheus‑text metrics collected in + * src/lib/telemetry.ts. + */ +export function metricsPlugin(): Plugin { + let telemetryModule: typeof import('./src/lib/telemetry') | null = null; + + return { + name: 'zeavis-metrics', + + configureServer(server) { + server.middlewares.use(async (req, res, next) => { + // Only handle GET /metrics + if (req.method !== 'GET' || !req.url?.startsWith('/metrics')) { + return next(); + } + + // Lazy‑load the telemetry module (ensures the app is bootstrapped first) + if (!telemetryModule) { + try { + telemetryModule = await server.ssrLoadModule('./src/lib/telemetry.ts') as typeof import('./src/lib/telemetry'); + } catch { + // If the module isn't ready yet, return an empty body + res.statusCode = 200; + res.setHeader('Content-Type', 'text/plain; charset=utf-8'); + res.end('# telemetry module not yet loaded\n'); + return; + } + } + + const body = telemetryModule.collectMetrics(); + res.statusCode = 200; + res.setHeader('Content-Type', 'text/plain; charset=utf-8'); + res.end(body); + }); + }, + }; +} diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 08b0a15..b596289 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -2,13 +2,14 @@ import react from '@vitejs/plugin-react'; import tsconfigPaths from 'vite-tsconfig-paths'; import path from 'node:path'; import { defineConfig, loadEnv } from 'vite'; +import { metricsPlugin } from './vite-plugin-metrics'; export default defineConfig(({ mode }) => { const env = loadEnv(mode, process.cwd(), ''); const apiProxyTarget = env.VITE_API_PROXY_TARGET || 'http://localhost:3000'; return { - plugins: [react(), tsconfigPaths()], + plugins: [react(), tsconfigPaths(), metricsPlugin()], server: { proxy: { '/api': apiProxyTarget,