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,