diff --git a/.env.example b/.env.example index f36cfc7..fdcdbb6 100644 --- a/.env.example +++ b/.env.example @@ -19,6 +19,9 @@ H5_PUBLIC_BASE_URL=http://127.0.0.1:5173 # MEMIND_ANALYTICS_WEBSITE_ID= # MEMIND_ANALYTICS_ID_SECRET= # MEMIND_ANALYTICS_DOMAINS=127.0.0.1,localhost +# Memind 产品壳层使用独立 Website,避免与用户生成页面的浏览口径混在一起 +# MEMIND_PRODUCT_ANALYTICS_ENABLED=true +# MEMIND_PRODUCT_ANALYTICS_WEBSITE_ID= # 生产 H5 public base 当前临时切到 https://mm.tkmind.cn。 # 后续公网 H5 不再依赖 105 转发链路;m.tkmind.cn 仅作为 legacy/rollback 记录处理。 diff --git a/docs/local-analytics.md b/docs/local-analytics.md index 5d659a7..803866b 100644 --- a/docs/local-analytics.md +++ b/docs/local-analytics.md @@ -9,8 +9,10 @@ local Umami service at `http://127.0.0.1:3100`; it does not contact 103/105. curl --fail http://127.0.0.1:3100/api/heartbeat ``` -2. Create one Umami Website for the local generated-page host. Do not create a - Website per page or per user. +2. Create two Umami Websites: one for generated pages and one for the Memind + product shell. Keeping them separate prevents chat, MindSpace, and feedback + navigation from inflating generated-page views. Do not create a Website per + page or per user. 3. Put the Website ID and a local-only pseudonymization secret in Memind's `.env`: @@ -21,6 +23,8 @@ local Umami service at `http://127.0.0.1:3100`; it does not contact 103/105. MEMIND_ANALYTICS_WEBSITE_ID= MEMIND_ANALYTICS_ID_SECRET= MEMIND_ANALYTICS_DOMAINS=127.0.0.1,localhost + MEMIND_PRODUCT_ANALYTICS_ENABLED=true + MEMIND_PRODUCT_ANALYTICS_WEBSITE_ID= ``` 4. Restart the local Memind server. Full generated HTML pages will receive a @@ -31,6 +35,14 @@ local Umami service at `http://127.0.0.1:3100`; it does not contact 103/105. operational analytics. Click, form, scroll, and engagement events retain the pseudonymous `owner_id`, `page_id`, and `channel` dimensions. +5. The product shell loads its tracker from the same-origin + `/analytics/script.js` endpoint. It records one standard page view per SPA + route transition, `product_click` for links/buttons, and 10/30-second route + engagement events. Query strings are restricted to a small allowlist and + click labels never copy arbitrary chat or generated-page text. Authenticated + product events use the same pseudonymous identity as generated pages, while + remaining in the separate product Website. + The integration is fail-open: missing configuration, disabled analytics, or a down Umami service leaves page generation and page delivery unchanged. User facing analytics must be queried through a future Memind API that filters by diff --git a/mindspace-analytics.mjs b/mindspace-analytics.mjs index 7312341..64ca40d 100644 --- a/mindspace-analytics.mjs +++ b/mindspace-analytics.mjs @@ -17,6 +17,22 @@ export function resolveMindSpaceAnalyticsConfig(env = process.env) { }; } +export function resolveProductAnalyticsConfig( + env = process.env, + baseConfig = resolveMindSpaceAnalyticsConfig(env), +) { + const websiteId = String(env.MEMIND_PRODUCT_ANALYTICS_WEBSITE_ID ?? '').trim(); + const enabled = String(env.MEMIND_PRODUCT_ANALYTICS_ENABLED ?? '').toLowerCase() === 'true'; + return { + enabled: enabled && Boolean(websiteId) && Boolean(baseConfig?.idSecret), + websiteId, + idSecret: String(baseConfig?.idSecret ?? '').trim(), + scriptPath: String(baseConfig?.scriptPath ?? '/analytics/script.js').trim() || '/analytics/script.js', + hostPath: String(baseConfig?.hostPath ?? '/analytics').trim() || '/analytics', + domains: String(baseConfig?.domains ?? '').trim(), + }; +} + export function pseudonymizeAnalyticsId(value, secret) { const normalized = String(value ?? '').trim(); const key = String(secret ?? '').trim(); @@ -35,6 +51,27 @@ export function resolveAnalyticsOwnerLabel(user = {}) { return label.replace(/[\r\n\t]+/g, ' ').slice(0, 80) || '未命名用户'; } +export function buildProductAnalyticsContext({ config, user = null } = {}) { + if (!config?.enabled || !config.websiteId) return { enabled: false }; + const distinctId = user?.id ? pseudonymizeAnalyticsId(user.id, config.idSecret) : ''; + return { + enabled: true, + websiteId: config.websiteId, + scriptPath: config.scriptPath || '/analytics/script.js', + hostPath: config.hostPath || '/analytics', + domains: config.domains || '', + identity: distinctId + ? { + distinctId, + username: resolveAnalyticsOwnerLabel(user), + ownerSegment: resolveAnalyticsOwnerSegment(user), + channel: 'h5', + surface: 'product', + } + : null, + }; +} + export function sendMindSpaceAnalyticsEvent({ config, eventName, @@ -98,7 +135,7 @@ export function injectMindSpaceAnalytics(html, { // The stable pseudonym remains the Umami identity key. The readable username // is an explicitly enabled analytics property so operators can recognize the // Memind user, while the current public page URL is resolved in the browser. - const metadata = { owner_id: owner, username: resolveAnalyticsOwnerLabel({ displayName: ownerLabel }), owner_segment: String(ownerSegment || 'unknown'), page_id: String(pageId || ''), publication_id: String(publicationId || ''), channel }; + const metadata = { owner_id: owner, username: resolveAnalyticsOwnerLabel({ displayName: ownerLabel }), owner_segment: String(ownerSegment || 'unknown'), page_id: String(pageId || ''), publication_id: String(publicationId || ''), channel, surface: 'generated_page' }; const attrs = [ ANALYTICS_MARKER, `data-website-id="${config.websiteId.replaceAll('"', '"')}"`, @@ -106,7 +143,7 @@ export function injectMindSpaceAnalytics(html, { `data-host-url="${config.hostPath}"`, ]; if (config.domains) attrs.push(`data-domains="${config.domains.replaceAll('"', '"')}"`); - const block = ``; + const block = ``; if (/<\/head>/i.test(source)) return source.replace(/<\/head>/i, `${block}`); return source.replace(/ { + const base = resolveMindSpaceAnalyticsConfig({ + MEMIND_ANALYTICS_ENABLED: 'true', + MEMIND_ANALYTICS_WEBSITE_ID: 'generated-pages', + MEMIND_ANALYTICS_ID_SECRET: 'local-secret', + }); + const config = resolveProductAnalyticsConfig({ + MEMIND_PRODUCT_ANALYTICS_ENABLED: 'true', + MEMIND_PRODUCT_ANALYTICS_WEBSITE_ID: 'product-shell', + }, base); + assert.equal(config.enabled, true); + assert.equal(config.websiteId, 'product-shell'); + assert.notEqual(config.websiteId, base.websiteId); + const context = buildProductAnalyticsContext({ + config, + user: { id: 'user-123', displayName: '张三', role: 'user', planType: 'pro' }, + }); + assert.deepEqual(context.identity, { + distinctId: pseudonymizeAnalyticsId('user-123', 'local-secret'), + username: '张三', + ownerSegment: 'plan:pro', + channel: 'h5', + surface: 'product', + }); +}); + +test('product analytics context stays public-safe before login', () => { + const context = buildProductAnalyticsContext({ + config: { + enabled: true, + websiteId: 'product-shell', + idSecret: 'local-secret', + scriptPath: '/analytics/script.js', + hostPath: '/analytics', + }, + }); + assert.equal(context.enabled, true); + assert.equal(context.websiteId, 'product-shell'); + assert.equal(context.identity, null); + assert.equal('idSecret' in context, false); +}); + test('analytics config is disabled unless explicitly enabled and configured', () => { assert.equal(resolveMindSpaceAnalyticsConfig({ MEMIND_ANALYTICS_ENABLED: 'true' }).enabled, false); assert.equal(resolveMindSpaceAnalyticsConfig({ @@ -65,7 +109,7 @@ test('injects one local same-origin tracker with page dimensions', () => { assert.match(out, /src="\/analytics\/script\.js"/); assert.match(out, /data-host-url="\/analytics"/); assert.match(out, /data-auto-track="false"/); - assert.match(out, /window\.umami\.identify\(d\.owner_id,\{username:d\.username,memind_page_url:location\.href,owner_segment:d\.owner_segment,channel:d\.channel\}\)/); + assert.match(out, /window\.umami\.identify\(d\.owner_id,\{username:d\.username,memind_page_url:location\.href,owner_segment:d\.owner_segment,channel:d\.channel,surface:d\.surface\}\)/); assert.match(out, /function pageview\(\).*window\.umami\.track\(\)/); assert.ok(out.indexOf('identify();pageview();') > 0); assert.doesNotMatch(out, /t\('page_view'\)/); @@ -74,6 +118,8 @@ test('injects one local same-origin tracker with page dimensions', () => { assert.doesNotMatch(out, /owner_label/); assert.match(out, /"username":"张三"/); assert.match(out, /page_click/); + assert.match(out, /target_path/); + assert.doesNotMatch(out, /target_url/); assert.match(out, /page_form_submit/); assert.match(out, /page_scroll_/); assert.match(out, /page_engaged_10s/); @@ -125,6 +171,7 @@ test('identifies the pseudonymous owner before sending a standard page view', () memind_page_url: 'https://m.tkmind.cn/MindSpace/demo/public/page.html', owner_segment: 'plan:pro', channel: 'h5', + surface: 'generated_page', }], ['track'], ]); diff --git a/server.mjs b/server.mjs index 735b03d..f018094 100644 --- a/server.mjs +++ b/server.mjs @@ -42,7 +42,7 @@ import { createWikiAuth } from './wiki-auth.mjs'; import { isLocalDevHostname } from './scripts/local-test-config.mjs'; import { PUBLISH_ROOT_DIR, PUBLISH_KEY_UUID, PUBLIC_ZONE_DIR } from './user-publish.mjs'; import { ensureWorkspaceHtmlThumbnail, startWorkspaceThumbnailWatcher, workspaceThumbnailRelativePath } from './mindspace-workspace-thumbnails.mjs'; -import { injectMindSpaceAnalytics, resolveAnalyticsOwnerLabel, resolveAnalyticsOwnerSegment, resolveMindSpaceAnalyticsConfig, sendMindSpaceAnalyticsEvent } from './mindspace-analytics.mjs'; +import { buildProductAnalyticsContext, injectMindSpaceAnalytics, resolveAnalyticsOwnerLabel, resolveAnalyticsOwnerSegment, resolveMindSpaceAnalyticsConfig, resolveProductAnalyticsConfig, sendMindSpaceAnalyticsEvent } from './mindspace-analytics.mjs'; import { startWorkspaceAssetSyncWatcher } from './mindspace-workspace-sync.mjs'; import { attachRequestId, sendData, sendError } from './api-response.mjs'; import { createNotificationDispatcher } from './notification-dispatcher.mjs'; @@ -2092,6 +2092,7 @@ api.use(async (req, res, next) => { const plazaPublic = isPlazaPublicRead(req.path, req.method); const pageDataPublic = isPageDataPublicPath(req.path, req.method); + const productAnalyticsPublic = req.method === 'GET' && req.path === '/analytics/context'; // Let the retired namespace reach its explicit 410 handler below. Without // this exception the outer auth middleware turns an old public HTML request // into a misleading 401/403 before the legacy-endpoint block can run. @@ -2099,7 +2100,7 @@ api.use(async (req, res, next) => { if (userAuth && tkmindProxy) { if (req.userSessionError) { - if (plazaPublic || pageDataPublic || legacyPageDataApi) return next(); + if (plazaPublic || pageDataPublic || legacyPageDataApi || productAnalyticsPublic) return next(); return res.status(503).json({ message: '用户认证服务不可用,请稍后重试' }); } try { @@ -2107,7 +2108,7 @@ api.use(async (req, res, next) => { const me = await userAuth.getMe(req.userToken); if (me) req.currentUser = me; } - if (plazaPublic || pageDataPublic || legacyPageDataApi) return next(); + if (plazaPublic || pageDataPublic || legacyPageDataApi || productAnalyticsPublic) return next(); if (!req.userSession) { return res.status(401).json({ message: '未授权,请重新登录' }); } @@ -2121,10 +2122,17 @@ api.use(async (req, res, next) => { } } + if (productAnalyticsPublic) return next(); if (legacyAuth?.verify(legacySessionToken(req))) return next(); return res.status(401).json({ message: '未授权,请重新登录' }); }); +api.get('/analytics/context', (req, res) => { + const config = resolveProductAnalyticsConfig(process.env, mindSpaceAnalyticsConfig); + res.set('Cache-Control', 'private, no-store'); + res.json(buildProductAnalyticsContext({ config, user: req.currentUser ?? null })); +}); + attachAsrRoutes(api, { sendError, sendData }); attachMindSpaceImageGenerationRoutes(api, { getService: () => mindSpaceImageGeneration, diff --git a/src/App.tsx b/src/App.tsx index 4e65dd8..e997097 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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(); const [legacyMode, setLegacyMode] = useState(false); const [authUnavailable, setAuthUnavailable] = useState(null); + useProductAnalytics(user?.id); useEffect(() => { if (mindSpacePreview) return; diff --git a/src/analytics/productAnalytics.ts b/src/analytics/productAnalytics.ts new file mode 100644 index 0000000..8269fe0 --- /dev/null +++ b/src/analytics/productAnalytics.ts @@ -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; +type UmamiTracker = { + track: ( + value?: string | ((properties: PageViewProperties) => PageViewProperties), + data?: Record, + ) => Promise | unknown; + identify: (id: string, data?: Record) => Promise | unknown; +}; + +declare global { + interface Window { + umami?: UmamiTracker; + } +} + +const SAFE_QUERY_KEYS = new Set(['category', 'utm_source', 'preview']); +let trackerLoad: Promise | 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((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((resolve, reject) => { + const existing = document.querySelector('#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 = {}, +) { + 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({ 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]); +} diff --git a/src/components/ChatView.tsx b/src/components/ChatView.tsx index d4c0539..270e63c 100644 --- a/src/components/ChatView.tsx +++ b/src/components/ChatView.tsx @@ -372,6 +372,7 @@ export function ChatView({ {onOpenAdmin && ( - )} @@ -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 && } {onLogout && ( - )} @@ -429,6 +431,7 @@ export function ChatView({