mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-15 19:39:40 +02:00
feat(analytics): add Web Vitals tracking, GlitchTip user context, and funnel events
- 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) <noreply@anthropic.com>
This commit is contained in:
parent
198720ca38
commit
f2d6573fa7
9 changed files with 188 additions and 3 deletions
|
|
@ -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);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
88
apps/manacore/apps/web/src/lib/stores/funnel-tracking.ts
Normal file
88
apps/manacore/apps/web/src/lib/stores/funnel-tracking.ts
Normal file
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
57
packages/shared-utils/src/web-vitals.ts
Normal file
57
packages/shared-utils/src/web-vitals.ts
Normal file
|
|
@ -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<string, [number, number]> = {
|
||||
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));
|
||||
}
|
||||
8
pnpm-lock.yaml
generated
8
pnpm-lock.yaml
generated
|
|
@ -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: {}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue