From f2d6573fa7483033911917c2f00821dc3f261348 Mon Sep 17 00:00:00 2001 From: Till JS Date: Thu, 2 Apr 2026 17:03:06 +0200 Subject: [PATCH] feat(analytics): add Web Vitals tracking, GlitchTip user context, and funnel events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add web-vitals package with LCP/CLS/INP/FCP/TTFB → Umami tracking - Set GlitchTip user context on login, clear on logout - Add funnel events: first_content_created, user_return_visit, second_module_used, guest_converted - Track first content via Dexie creating hook (fires once per user) - Track module usage via route navigation effect - Track guest→registered conversion on signup Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/manacore/apps/web/src/hooks.client.ts | 3 + .../apps/web/src/lib/data/database.ts | 2 + .../web/src/lib/stores/funnel-tracking.ts | 88 +++++++++++++++++++ .../apps/web/src/routes/(app)/+layout.svelte | 12 +++ .../src/routes/(auth)/register/+page.svelte | 5 +- packages/shared-utils/package.json | 6 +- packages/shared-utils/src/analytics.ts | 10 +++ packages/shared-utils/src/web-vitals.ts | 57 ++++++++++++ pnpm-lock.yaml | 8 ++ 9 files changed, 188 insertions(+), 3 deletions(-) create mode 100644 apps/manacore/apps/web/src/lib/stores/funnel-tracking.ts create mode 100644 packages/shared-utils/src/web-vitals.ts diff --git a/apps/manacore/apps/web/src/hooks.client.ts b/apps/manacore/apps/web/src/hooks.client.ts index 5f56d4358..d0759b0e0 100644 --- a/apps/manacore/apps/web/src/hooks.client.ts +++ b/apps/manacore/apps/web/src/hooks.client.ts @@ -1,4 +1,5 @@ import { initErrorTracking, handleSvelteError } from '@manacore/shared-error-tracking/browser'; +import { trackWebVitals } from '@manacore/shared-utils/web-vitals'; import type { HandleClientError } from '@sveltejs/kit'; initErrorTracking({ @@ -7,6 +8,8 @@ initErrorTracking({ environment: import.meta.env.MODE, }); +trackWebVitals(); + export const handleError: HandleClientError = ({ error }) => { handleSvelteError(error); }; diff --git a/apps/manacore/apps/web/src/lib/data/database.ts b/apps/manacore/apps/web/src/lib/data/database.ts index 476e0d415..4ce645c98 100644 --- a/apps/manacore/apps/web/src/lib/data/database.ts +++ b/apps/manacore/apps/web/src/lib/data/database.ts @@ -8,6 +8,7 @@ */ import Dexie, { type EntityTable } from 'dexie'; +import { trackFirstContent } from '$lib/stores/funnel-tracking'; // ─── Database ────────────────────────────────────────────── @@ -344,6 +345,7 @@ for (const [appId, tables] of Object.entries(SYNC_APP_MAP)) { data: { ...obj }, createdAt: now, }); + trackFirstContent(appId); }); table.hook('updating', function (modifications, primKey) { diff --git a/apps/manacore/apps/web/src/lib/stores/funnel-tracking.ts b/apps/manacore/apps/web/src/lib/stores/funnel-tracking.ts new file mode 100644 index 000000000..c60370ef9 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/stores/funnel-tracking.ts @@ -0,0 +1,88 @@ +/** + * Funnel Tracking — fires one-time activation & retention events. + * + * Each event uses a localStorage flag so it fires at most once per user/device. + * These events power Umami funnels: + * signup → onboarding_completed → first_content_created → second_module_used → user_return_visit + */ + +import { ManaCoreEvents } from '@manacore/shared-utils/analytics'; + +const KEYS = { + firstContent: 'mana_funnel_first_content', + returnVisit: 'mana_funnel_return_visit', + modulesUsed: 'mana_funnel_modules', + wasGuest: 'mana_funnel_was_guest', +} as const; + +function flagged(key: string): boolean { + return localStorage.getItem(key) === '1'; +} + +function flag(key: string): void { + localStorage.setItem(key, '1'); +} + +/** + * Call on app init (authenticated users). + * Fires `user_return_visit` on the second session. + */ +export function trackReturnVisit(): void { + if (flagged(KEYS.returnVisit)) return; + + const lastVisit = localStorage.getItem('mana_last_visit'); + const now = new Date().toISOString().slice(0, 10); // YYYY-MM-DD + + if (lastVisit && lastVisit !== now) { + ManaCoreEvents.userReturnVisit(); + flag(KEYS.returnVisit); + } + + localStorage.setItem('mana_last_visit', now); +} + +/** + * Call when a user creates content in any module. + * Fires `first_content_created` exactly once. + */ +export function trackFirstContent(appId: string): void { + if (flagged(KEYS.firstContent)) return; + ManaCoreEvents.firstContentCreated(appId); + flag(KEYS.firstContent); +} + +/** + * Call when a user navigates to a module. + * Fires `second_module_used` when they visit a second distinct module. + */ +export function trackModuleUsed(appId: string): void { + const raw = localStorage.getItem(KEYS.modulesUsed); + const modules: string[] = raw ? JSON.parse(raw) : []; + + if (modules.includes(appId)) return; + modules.push(appId); + localStorage.setItem(KEYS.modulesUsed, JSON.stringify(modules)); + + if (modules.length === 2) { + ManaCoreEvents.secondModuleUsed(appId); + } +} + +/** + * Mark the current session as guest (call before auth). + */ +export function markAsGuest(): void { + if (!flagged(KEYS.wasGuest)) { + flag(KEYS.wasGuest); + } +} + +/** + * Call after signup. Fires `guest_converted` if the user was previously a guest. + */ +export function trackGuestConversion(): void { + if (flagged(KEYS.wasGuest)) { + ManaCoreEvents.guestConverted(); + localStorage.removeItem(KEYS.wasGuest); + } +} diff --git a/apps/manacore/apps/web/src/routes/(app)/+layout.svelte b/apps/manacore/apps/web/src/routes/(app)/+layout.svelte index 8d5938cd3..82f9cd9ec 100644 --- a/apps/manacore/apps/web/src/routes/(app)/+layout.svelte +++ b/apps/manacore/apps/web/src/routes/(app)/+layout.svelte @@ -40,6 +40,8 @@ import { getLanguageDropdownItems, getCurrentLanguageLabel } from '@manacore/shared-i18n'; import { setLocale, supportedLocales } from '$lib/i18n'; import { ManaCoreEvents, AppEvents } from '@manacore/shared-utils/analytics'; + import { setUser as setErrorTrackingUser } from '@manacore/shared-error-tracking/browser'; + import { trackReturnVisit, trackModuleUsed, markAsGuest } from '$lib/stores/funnel-tracking'; import { theme } from '$lib/stores/theme'; import { authStore } from '$lib/stores/auth.svelte'; import { userSettings } from '$lib/stores/user-settings.svelte'; @@ -196,6 +198,7 @@ async function handleSignOut() { unifiedSync?.stopAll(); guestMode?.destroy(); + setErrorTrackingUser(null); await authStore.signOut(); goto('/login'); } @@ -239,6 +242,8 @@ // Phase B: Auth-dependent — sync, settings, onboarding if (authStore.isAuthenticated) { + setErrorTrackingUser({ id: authStore.user?.id ?? 'unknown', email: authStore.user?.email }); + trackReturnVisit(); const getToken = () => authStore.getValidToken(); unifiedSync = createUnifiedSync(SYNC_SERVER_URL, getToken); unifiedSync.startAll(); @@ -255,6 +260,7 @@ // Phase C: Guest mode — welcome modal + nudge if (!authStore.isAuthenticated) { + markAsGuest(); guestMode = createGuestMode('manacore', { nudgeDelayMinutes: 3, onRegister: () => goto('/register'), @@ -285,6 +291,12 @@ if (moduleSlug === activeModulePrefix) return; + // Track module usage for funnel analysis + const moduleName = pathname.split('/')[1]; + if (moduleName && authStore.isAuthenticated) { + trackModuleUsed(moduleName); + } + const loader = getAdapterLoader(pathname); if (!loader) { inputBarAdapter = createFallbackAdapter(searchRegistry); diff --git a/apps/manacore/apps/web/src/routes/(auth)/register/+page.svelte b/apps/manacore/apps/web/src/routes/(auth)/register/+page.svelte index 3f1dcb07b..2d4348924 100644 --- a/apps/manacore/apps/web/src/routes/(auth)/register/+page.svelte +++ b/apps/manacore/apps/web/src/routes/(auth)/register/+page.svelte @@ -6,13 +6,16 @@ import { ManaCoreLogo } from '@manacore/shared-branding'; import AppSlider from '$lib/components/AppSlider.svelte'; import { authStore } from '$lib/stores/auth.svelte'; + import { trackGuestConversion } from '$lib/stores/funnel-tracking'; import '$lib/i18n'; // Get translations based on current locale const translations = $derived(getRegisterTranslations($locale || 'de')); async function handleSignUp(email: string, password: string) { - return authStore.signUp(email, password); + const result = await authStore.signUp(email, password); + if (result.success) trackGuestConversion(); + return result; } async function handleResendVerification(email: string) { diff --git a/packages/shared-utils/package.json b/packages/shared-utils/package.json index 38b3d1b7b..07a421271 100644 --- a/packages/shared-utils/package.json +++ b/packages/shared-utils/package.json @@ -9,7 +9,8 @@ ".": "./src/index.ts", "./analytics": "./src/analytics.ts", "./analytics-server": "./src/analytics-server.ts", - "./security-headers": "./src/security-headers.ts" + "./security-headers": "./src/security-headers.ts", + "./web-vitals": "./src/web-vitals.ts" }, "scripts": { "type-check": "tsc --noEmit", @@ -17,7 +18,8 @@ "lint": "eslint ." }, "dependencies": { - "date-fns": "^4.1.0" + "date-fns": "^4.1.0", + "web-vitals": "^5.2.0" }, "devDependencies": { "@types/node": "^24.10.1", diff --git a/packages/shared-utils/src/analytics.ts b/packages/shared-utils/src/analytics.ts index 67ddd13a4..06c93ffa4 100644 --- a/packages/shared-utils/src/analytics.ts +++ b/packages/shared-utils/src/analytics.ts @@ -328,6 +328,16 @@ export const ManaCoreEvents = { track.manacore('widget_resized', { widget_type: widgetType, size }), creditsTabViewed: (tab: string) => track.manacore('credits_tab_viewed', { tab }), profileUpdated: () => track.manacore('profile_updated'), + + // Funnel events — track key activation & retention moments + /** User created their first piece of content in any module */ + firstContentCreated: (appId: string) => track.manacore('first_content_created', { app: appId }), + /** User returned after first session (fired once per user) */ + userReturnVisit: () => track.manacore('user_return_visit'), + /** User used a second module (cross-app engagement) */ + secondModuleUsed: (appId: string) => track.manacore('second_module_used', { app: appId }), + /** Guest user converted to registered user */ + guestConverted: () => track.manacore('guest_converted'), }; /** diff --git a/packages/shared-utils/src/web-vitals.ts b/packages/shared-utils/src/web-vitals.ts new file mode 100644 index 000000000..2e0114884 --- /dev/null +++ b/packages/shared-utils/src/web-vitals.ts @@ -0,0 +1,57 @@ +/** + * Web Vitals → Umami Integration + * + * Tracks Core Web Vitals (LCP, CLS, INP) and additional metrics (FCP, TTFB) + * as Umami events. Call `trackWebVitals()` once on app startup. + * + * @example + * ```typescript + * import { trackWebVitals } from '@manacore/shared-utils/web-vitals'; + * trackWebVitals(); + * ``` + */ + +import { onCLS, onINP, onLCP, onFCP, onTTFB } from 'web-vitals'; +import { trackEvent } from './analytics'; + +/** + * Rating thresholds per metric (good / needs-improvement / poor). + * Based on https://web.dev/articles/vitals + */ +function getRating(name: string, value: number): 'good' | 'needs-improvement' | 'poor' { + const thresholds: Record = { + CLS: [0.1, 0.25], + INP: [200, 500], + LCP: [2500, 4000], + FCP: [1800, 3000], + TTFB: [800, 1800], + }; + const [good, poor] = thresholds[name] ?? [Infinity, Infinity]; + if (value <= good) return 'good'; + if (value <= poor) return 'needs-improvement'; + return 'poor'; +} + +function reportMetric(name: string, value: number): void { + const rounded = name === 'CLS' ? Math.round(value * 1000) / 1000 : Math.round(value); + trackEvent('web_vital', { + metric: name, + value: rounded, + rating: getRating(name, value), + module: 'performance', + }); +} + +/** + * Start tracking all Core Web Vitals + FCP/TTFB. + * Each metric fires once per page load when the browser has a final value. + */ +export function trackWebVitals(): void { + if (typeof window === 'undefined') return; + + onCLS((m) => reportMetric('CLS', m.value)); + onINP((m) => reportMetric('INP', m.value)); + onLCP((m) => reportMetric('LCP', m.value)); + onFCP((m) => reportMetric('FCP', m.value)); + onTTFB((m) => reportMetric('TTFB', m.value)); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7e7f0982a..311a557b4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6856,6 +6856,9 @@ importers: date-fns: specifier: ^4.1.0 version: 4.1.0 + web-vitals: + specifier: ^5.2.0 + version: 5.2.0 devDependencies: '@types/node': specifier: ^24.10.1 @@ -23117,6 +23120,9 @@ packages: resolution: {integrity: sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==} engines: {node: '>= 14'} + web-vitals@5.2.0: + resolution: {integrity: sha512-i2z98bEmaCqSDiHEDu+gHl/dmR4Q+TxFmG3/13KkMO+o8UxQzCqWaDRCiLgEa41nlO4VpXSI0ASa1xWmO9sBlA==} + webidl-conversions@3.0.1: resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} @@ -49622,6 +49628,8 @@ snapshots: web-streams-polyfill@4.0.0-beta.3: {} + web-vitals@5.2.0: {} + webidl-conversions@3.0.1: {} webidl-conversions@4.0.2: {}