feat: track Memind product analytics

This commit is contained in:
john
2026-07-20 20:19:52 +08:00
parent 258e4a8a07
commit d479e89fc7
10 changed files with 446 additions and 8 deletions
+2
View File
@@ -10,6 +10,7 @@ import { ChatProvider } from './context/ChatProvider';
import { PREVIEW_USER } from './dev/mindspacePreviewData';
import { MindSpaceRoute } from './routes/MindSpaceRoute';
import { FeedbackRoutes } from './routes/FeedbackRoute';
import { useProductAnalytics } from './analytics/productAnalytics';
import type { CapabilityMap, PortalUser } from './types';
function isMindSpacePreview() {
@@ -124,6 +125,7 @@ export function App() {
const [grantedSkills, setGrantedSkills] = useState<string[] | undefined>();
const [legacyMode, setLegacyMode] = useState(false);
const [authUnavailable, setAuthUnavailable] = useState<string | null>(null);
useProductAnalytics(user?.id);
useEffect(() => {
if (mindSpacePreview) return;
+263
View File
@@ -0,0 +1,263 @@
import { useEffect, useRef, useState } from 'react';
import { useLocation } from 'react-router-dom';
type AnalyticsIdentity = {
distinctId: string;
username: string;
ownerSegment: string;
channel: string;
surface: 'product';
};
type ProductAnalyticsContext = {
enabled: boolean;
websiteId?: string;
scriptPath?: string;
hostPath?: string;
domains?: string;
identity?: AnalyticsIdentity | null;
};
type PageViewProperties = Record<string, unknown>;
type UmamiTracker = {
track: (
value?: string | ((properties: PageViewProperties) => PageViewProperties),
data?: Record<string, unknown>,
) => Promise<unknown> | unknown;
identify: (id: string, data?: Record<string, unknown>) => Promise<unknown> | unknown;
};
declare global {
interface Window {
umami?: UmamiTracker;
}
}
const SAFE_QUERY_KEYS = new Set(['category', 'utm_source', 'preview']);
let trackerLoad: Promise<void> | null = null;
let lastPageViewKey = '';
let lastIdentityKey = '';
export function normalizeProductRoute(pathname: string, search = '') {
const params = new URLSearchParams(search);
const safe = new URLSearchParams();
for (const key of SAFE_QUERY_KEYS) {
const value = params.get(key);
if (value) safe.set(key, value.slice(0, 100));
}
const query = safe.toString();
return `${pathname || '/'}${query ? `?${query}` : ''}`;
}
export function resolveProductRouteName(pathname: string) {
if (pathname === '/') return 'chat';
if (pathname === '/space') return 'mindspace_home';
if (pathname.startsWith('/space/page/')) return 'mindspace_page';
if (pathname.startsWith('/feedback/')) return 'feedback_detail';
if (pathname === '/feedback') return 'feedback';
return 'other';
}
function safeTargetPath(element: Element) {
if (!(element instanceof HTMLAnchorElement) || !element.href) return '';
try {
const target = new URL(element.href, window.location.href);
return target.origin === window.location.origin
? target.pathname
: `external:${target.hostname}`;
} catch {
return '';
}
}
function actionName(element: Element) {
const stableClasses = Array.from(element.classList)
.filter((name) => /^[a-z][a-z0-9_-]{1,80}$/i.test(name))
.slice(0, 3)
.join('.');
return String(
element.getAttribute('data-analytics-action') ||
element.getAttribute('data-umami-event') ||
element.getAttribute('aria-label') ||
element.getAttribute('title') ||
element.id ||
stableClasses ||
element.tagName.toLowerCase(),
).slice(0, 100);
}
function waitForTracker() {
if (window.umami) return Promise.resolve();
return new Promise<void>((resolve, reject) => {
const startedAt = Date.now();
const timer = window.setInterval(() => {
if (window.umami) {
window.clearInterval(timer);
resolve();
} else if (Date.now() - startedAt > 5000) {
window.clearInterval(timer);
reject(new Error('Umami tracker did not initialize'));
}
}, 25);
});
}
function loadTracker(context: ProductAnalyticsContext) {
if (window.umami) return Promise.resolve();
if (trackerLoad) return trackerLoad;
trackerLoad = new Promise<void>((resolve, reject) => {
const existing = document.querySelector<HTMLScriptElement>('#memind-product-analytics');
if (existing) {
void waitForTracker().then(resolve, reject);
return;
}
const script = document.createElement('script');
script.id = 'memind-product-analytics';
script.defer = true;
script.src = context.scriptPath || '/analytics/script.js';
script.dataset.websiteId = context.websiteId || '';
script.dataset.hostUrl = context.hostPath || '/analytics';
script.dataset.autoTrack = 'false';
if (context.domains) script.dataset.domains = context.domains;
script.addEventListener('load', () => void waitForTracker().then(resolve, reject), { once: true });
script.addEventListener('error', () => reject(new Error('Unable to load Umami tracker')), {
once: true,
});
document.head.appendChild(script);
}).catch((error) => {
trackerLoad = null;
throw error;
});
return trackerLoad;
}
function absoluteRoute(route: string) {
return new URL(route, window.location.origin).toString();
}
function trackPageView(route: string) {
return window.umami?.track((properties) => ({
...properties,
url: absoluteRoute(route),
title: document.title,
}));
}
function trackProductEvent(
eventName: string,
route: string,
identity: AnalyticsIdentity | null | undefined,
data: Record<string, unknown> = {},
) {
return window.umami?.track((properties) => ({
...properties,
name: eventName,
url: absoluteRoute(route),
title: document.title,
data: {
surface: 'product',
route,
route_name: resolveProductRouteName(window.location.pathname),
channel: identity?.channel || 'h5',
owner_segment: identity?.ownerSegment || 'anonymous',
...data,
},
}));
}
export function useProductAnalytics(userId?: string | null) {
const location = useLocation();
const [context, setContext] = useState<ProductAnalyticsContext>({ enabled: false });
const [ready, setReady] = useState(false);
const contextRef = useRef(context);
const route = normalizeProductRoute(location.pathname, location.search);
const routeRef = useRef(route);
contextRef.current = context;
routeRef.current = route;
useEffect(() => {
let cancelled = false;
void fetch('/api/analytics/context', { credentials: 'same-origin', cache: 'no-store' })
.then(async (response) => (response.ok ? ((await response.json()) as ProductAnalyticsContext) : null))
.then((next) => {
if (!cancelled && next) setContext(next);
})
.catch(() => undefined);
return () => {
cancelled = true;
};
}, [userId]);
useEffect(() => {
if (!context.enabled || !context.websiteId) return;
let cancelled = false;
void loadTracker(context)
.then(() => {
if (cancelled) return;
const identity = context.identity;
if (identity?.distinctId) {
const identityKey = `${context.websiteId}:${identity.distinctId}`;
if (lastIdentityKey !== identityKey) {
lastIdentityKey = identityKey;
void window.umami?.identify(identity.distinctId, {
username: identity.username,
owner_segment: identity.ownerSegment,
channel: identity.channel,
surface: identity.surface,
});
}
} else if (lastIdentityKey) {
lastIdentityKey = '';
void window.umami?.identify('', {
auth_state: 'anonymous',
channel: 'h5',
surface: 'product',
});
}
setReady(true);
})
.catch(() => undefined);
return () => {
cancelled = true;
};
}, [context]);
useEffect(() => {
if (!ready || !context.enabled || !context.websiteId) return;
const pageViewKey = `${context.websiteId}:${route}`;
if (lastPageViewKey === pageViewKey) return;
lastPageViewKey = pageViewKey;
void trackPageView(route);
const tenSecond = window.setTimeout(() => {
void trackProductEvent('product_engaged_10s', route, context.identity);
}, 10_000);
const thirtySecond = window.setTimeout(() => {
void trackProductEvent('product_engaged_30s', route, context.identity);
}, 30_000);
return () => {
window.clearTimeout(tenSecond);
window.clearTimeout(thirtySecond);
};
}, [context, ready, route]);
useEffect(() => {
if (!ready || !context.enabled) return;
const handleClick = (event: MouseEvent) => {
const target = event.target instanceof Element
? event.target.closest('a,button,[role="button"],[data-analytics-action],[data-umami-event]')
: null;
if (!target) return;
void trackProductEvent('product_click', routeRef.current, contextRef.current.identity, {
action: actionName(target),
element: target.tagName.toLowerCase(),
element_id: target.id.slice(0, 100),
element_class: Array.from(target.classList).slice(0, 3).join(' ').slice(0, 200),
target_path: safeTargetPath(target),
});
};
document.addEventListener('click', handleClick, { capture: true, passive: true });
return () => document.removeEventListener('click', handleClick, { capture: true });
}, [context.enabled, ready]);
}
+6 -2
View File
@@ -372,6 +372,7 @@ export function ChatView({
<button
type="button"
className="header-icon-btn"
data-analytics-action="chat_new"
aria-label="新聊天"
title="新聊天"
onClick={() => void handleNewSession()}
@@ -386,7 +387,7 @@ export function ChatView({
</svg>
</button>
{onOpenAdmin && (
<button type="button" className="ghost-btn" onClick={onOpenAdmin}>
<button type="button" className="ghost-btn" data-analytics-action="open_admin" onClick={onOpenAdmin}>
</button>
)}
@@ -395,6 +396,7 @@ export function ChatView({
ref={spaceButtonRef}
type="button"
className="ghost-btn"
data-analytics-action="open_mindspace"
onClick={() => onOpenSpace?.()}
>
@@ -402,7 +404,7 @@ export function ChatView({
)}
{user && <WechatAccountButton returnTo={window.location.pathname} />}
{onLogout && (
<button type="button" className="ghost-btn logout-btn" onClick={onLogout}>
<button type="button" className="ghost-btn logout-btn" data-analytics-action="logout" onClick={onLogout}>
</button>
)}
@@ -429,6 +431,7 @@ export function ChatView({
<button
type="button"
className="header-icon-btn"
data-analytics-action="chat_new"
aria-label="新聊天"
title="新聊天"
onClick={() => void handleNewSession()}
@@ -446,6 +449,7 @@ export function ChatView({
<button
type="button"
className="header-icon-btn"
data-analytics-action="open_mindspace"
aria-label="我的空间"
title="我的空间"
onClick={() => onOpenSpace?.()}