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
+3
View File
@@ -19,6 +19,9 @@ H5_PUBLIC_BASE_URL=http://127.0.0.1:5173
# MEMIND_ANALYTICS_WEBSITE_ID=<local Umami Website ID> # MEMIND_ANALYTICS_WEBSITE_ID=<local Umami Website ID>
# MEMIND_ANALYTICS_ID_SECRET=<local-only pseudonymization secret> # MEMIND_ANALYTICS_ID_SECRET=<local-only pseudonymization secret>
# MEMIND_ANALYTICS_DOMAINS=127.0.0.1,localhost # MEMIND_ANALYTICS_DOMAINS=127.0.0.1,localhost
# Memind 产品壳层使用独立 Website,避免与用户生成页面的浏览口径混在一起
# MEMIND_PRODUCT_ANALYTICS_ENABLED=true
# MEMIND_PRODUCT_ANALYTICS_WEBSITE_ID=<local Umami product Website ID>
# Local Rybbit analytics (same-origin /rybbit proxy to rybbit.tkmind.cn). # Local Rybbit analytics (same-origin /rybbit proxy to rybbit.tkmind.cn).
# Create a Rybbit Site for localhost / 127.0.0.1, then set its numeric site_id. # Create a Rybbit Site for localhost / 127.0.0.1, then set its numeric site_id.
+17
View File
@@ -13,6 +13,23 @@ MEMIND_ANALYTICS_ID_SECRET=<random-local-secret>
MEMIND_ANALYTICS_DOMAINS=127.0.0.1,localhost MEMIND_ANALYTICS_DOMAINS=127.0.0.1,localhost
``` ```
Create a second Umami Website for the Memind product shell. Keeping it separate
prevents chat, MindSpace, and feedback navigation from inflating generated-page
views. Do not create a Website per page or per user.
```dotenv
MEMIND_PRODUCT_ANALYTICS_ENABLED=true
MEMIND_PRODUCT_ANALYTICS_WEBSITE_ID=<product-website-id>
```
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.
## Rybbit (recommended for behavior analytics) ## Rybbit (recommended for behavior analytics)
Rybbit runs on 105 as `https://rybbit.tkmind.cn`. Local Memind does not talk to Rybbit runs on 105 as `https://rybbit.tkmind.cn`. Local Memind does not talk to
+39 -2
View File
@@ -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) { export function pseudonymizeAnalyticsId(value, secret) {
const normalized = String(value ?? '').trim(); const normalized = String(value ?? '').trim();
const key = String(secret ?? '').trim(); const key = String(secret ?? '').trim();
@@ -35,6 +51,27 @@ export function resolveAnalyticsOwnerLabel(user = {}) {
return label.replace(/[\r\n\t]+/g, ' ').slice(0, 80) || '未命名用户'; 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({ export function sendMindSpaceAnalyticsEvent({
config, config,
eventName, eventName,
@@ -98,7 +135,7 @@ export function injectMindSpaceAnalytics(html, {
// The stable pseudonym remains the Umami identity key. The readable username // The stable pseudonym remains the Umami identity key. The readable username
// is an explicitly enabled analytics property so operators can recognize the // is an explicitly enabled analytics property so operators can recognize the
// Memind user, while the current public page URL is resolved in the browser. // 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 = [ const attrs = [
ANALYTICS_MARKER, ANALYTICS_MARKER,
`data-website-id="${config.websiteId.replaceAll('"', '&quot;')}"`, `data-website-id="${config.websiteId.replaceAll('"', '&quot;')}"`,
@@ -106,7 +143,7 @@ export function injectMindSpaceAnalytics(html, {
`data-host-url="${config.hostPath}"`, `data-host-url="${config.hostPath}"`,
]; ];
if (config.domains) attrs.push(`data-domains="${config.domains.replaceAll('"', '&quot;')}"`); if (config.domains) attrs.push(`data-domains="${config.domains.replaceAll('"', '&quot;')}"`);
const block = `<script ${attrs.join(' ')} defer src="${config.scriptPath}"></script><script ${ANALYTICS_MARKER}>(function(){var d=${jsonForInlineScript(metadata)},seen={};function t(n,x){if(!window.umami||typeof window.umami.track!=='function')return;var p=Object.assign({},d,{page_url:location.href,page_title:document.title},x||{});window.umami.track(n,p);}function identify(){if(!window.umami||typeof window.umami.identify!=='function')return;window.umami.identify(d.owner_id,{username:d.username,memind_page_url:location.href,owner_segment:d.owner_segment,channel:d.channel});}function pageview(){if(!window.umami||typeof window.umami.track!=='function')return;window.umami.track();}function once(n,x){if(seen[n])return;seen[n]=1;t(n,x);}function ready(){identify();pageview();document.addEventListener('click',function(e){var el=e.target&&e.target.closest?e.target.closest('a,button,[role="button"],[data-umami-event]'):null;if(!el)return;var custom=el.getAttribute('data-umami-event');var href=el.tagName==='A'?el.getAttribute('href')||'':'';t(custom||'page_click',{element:el.tagName.toLowerCase(),element_id:el.id||'',event_label:(custom||el.getAttribute('aria-label')||'').slice(0,100),target_url:href.slice(0,500)});},{passive:true});document.addEventListener('submit',function(e){var form=e.target;t('page_form_submit',{form_id:form&&form.id||'',form_action:form&&form.getAttribute('action')||''});},{passive:true});var marks=[25,50,75,90];function scroll(){var h=document.documentElement.scrollHeight-window.innerHeight;if(h<=0){once('page_scroll_100');return;}var pct=Math.round(window.scrollY/h*100);marks.forEach(function(m){if(pct>=m)once('page_scroll_'+m);});}window.addEventListener('scroll',scroll,{passive:true});setTimeout(function(){once('page_engaged_10s');},10000);setTimeout(function(){once('page_engaged_30s');},30000);scroll();}if(document.readyState==='loading'){document.addEventListener('DOMContentLoaded',ready,{once:true});}else{ready();}})();</script>`; const block = `<script ${attrs.join(' ')} defer src="${config.scriptPath}"></script><script ${ANALYTICS_MARKER}>(function(){var d=${jsonForInlineScript(metadata)},seen={};function safeTarget(h){if(!h)return'';try{var u=new URL(h,location.href);return u.origin===location.origin?u.pathname:'external:'+u.hostname;}catch{return'';}}function t(n,x){if(!window.umami||typeof window.umami.track!=='function')return;var p=Object.assign({},d,{page_url:location.href,page_route:location.pathname,page_title:document.title},x||{});window.umami.track(n,p);}function identify(){if(!window.umami||typeof window.umami.identify!=='function')return;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});}function pageview(){if(!window.umami||typeof window.umami.track!=='function')return;window.umami.track();}function once(n,x){if(seen[n])return;seen[n]=1;t(n,x);}function ready(){identify();pageview();document.addEventListener('click',function(e){var el=e.target&&e.target.closest?e.target.closest('a,button,[role="button"],[data-umami-event]'):null;if(!el)return;var custom=el.getAttribute('data-umami-event');var href=el.tagName==='A'?el.getAttribute('href')||'':'';t(custom||'page_click',{element:el.tagName.toLowerCase(),element_id:el.id||'',event_label:(custom||el.getAttribute('aria-label')||'').slice(0,100),target_path:safeTarget(href)});},{passive:true});document.addEventListener('submit',function(e){var form=e.target;t('page_form_submit',{form_id:form&&form.id||'',form_action:safeTarget(form&&form.getAttribute('action')||'')});},{passive:true});var marks=[25,50,75,90];function scroll(){var h=document.documentElement.scrollHeight-window.innerHeight;if(h<=0){once('page_scroll_100');return;}var pct=Math.round(window.scrollY/h*100);marks.forEach(function(m){if(pct>=m)once('page_scroll_'+m);});}window.addEventListener('scroll',scroll,{passive:true});setTimeout(function(){once('page_engaged_10s');},10000);setTimeout(function(){once('page_engaged_30s');},30000);scroll();}if(document.readyState==='loading'){document.addEventListener('DOMContentLoaded',ready,{once:true});}else{ready();}})();</script>`;
if (/<\/head>/i.test(source)) return source.replace(/<\/head>/i, `${block}</head>`); if (/<\/head>/i.test(source)) return source.replace(/<\/head>/i, `${block}</head>`);
return source.replace(/<body\b/i, `${block}<body`); return source.replace(/<body\b/i, `${block}<body`);
} }
+48 -1
View File
@@ -3,14 +3,58 @@ import assert from 'node:assert/strict';
import vm from 'node:vm'; import vm from 'node:vm';
import { import {
buildProductAnalyticsContext,
injectMindSpaceAnalytics, injectMindSpaceAnalytics,
pseudonymizeAnalyticsId, pseudonymizeAnalyticsId,
resolveAnalyticsOwnerSegment, resolveAnalyticsOwnerSegment,
resolveAnalyticsOwnerLabel, resolveAnalyticsOwnerLabel,
resolveMindSpaceAnalyticsConfig, resolveMindSpaceAnalyticsConfig,
resolveProductAnalyticsConfig,
sendMindSpaceAnalyticsEvent, sendMindSpaceAnalyticsEvent,
} from './mindspace-analytics.mjs'; } from './mindspace-analytics.mjs';
test('product analytics uses a separate website and the existing pseudonymization secret', () => {
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', () => { test('analytics config is disabled unless explicitly enabled and configured', () => {
assert.equal(resolveMindSpaceAnalyticsConfig({ MEMIND_ANALYTICS_ENABLED: 'true' }).enabled, false); assert.equal(resolveMindSpaceAnalyticsConfig({ MEMIND_ANALYTICS_ENABLED: 'true' }).enabled, false);
assert.equal(resolveMindSpaceAnalyticsConfig({ 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, /src="\/analytics\/script\.js"/);
assert.match(out, /data-host-url="\/analytics"/); assert.match(out, /data-host-url="\/analytics"/);
assert.match(out, /data-auto-track="false"/); 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.match(out, /function pageview\(\).*window\.umami\.track\(\)/);
assert.ok(out.indexOf('identify();pageview();') > 0); assert.ok(out.indexOf('identify();pageview();') > 0);
assert.doesNotMatch(out, /t\('page_view'\)/); 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.doesNotMatch(out, /owner_label/);
assert.match(out, /"username":"张三"/); assert.match(out, /"username":"张三"/);
assert.match(out, /page_click/); 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_form_submit/);
assert.match(out, /page_scroll_/); assert.match(out, /page_scroll_/);
assert.match(out, /page_engaged_10s/); 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', memind_page_url: 'https://m.tkmind.cn/MindSpace/demo/public/page.html',
owner_segment: 'plan:pro', owner_segment: 'plan:pro',
channel: 'h5', channel: 'h5',
surface: 'generated_page',
}], }],
['track'], ['track'],
]); ]);
+14 -1
View File
@@ -13,7 +13,12 @@ import { createWikiAuth } from './wiki-auth.mjs';
import { isLocalDevHostname } from './scripts/local-test-config.mjs'; import { isLocalDevHostname } from './scripts/local-test-config.mjs';
import { PUBLISH_ROOT_DIR } from './user-publish.mjs'; import { PUBLISH_ROOT_DIR } from './user-publish.mjs';
import { startWorkspaceThumbnailWatcher } from './mindspace-workspace-thumbnails.mjs'; import { startWorkspaceThumbnailWatcher } from './mindspace-workspace-thumbnails.mjs';
import { resolveMindSpaceAnalyticsConfig, sendMindSpaceAnalyticsEvent } from './mindspace-analytics.mjs'; import {
buildProductAnalyticsContext,
resolveMindSpaceAnalyticsConfig,
resolveProductAnalyticsConfig,
sendMindSpaceAnalyticsEvent,
} from './mindspace-analytics.mjs';
import { resolveMindSpaceRybbitConfig } from './mindspace-rybbit.mjs'; import { resolveMindSpaceRybbitConfig } from './mindspace-rybbit.mjs';
import { startWorkspaceAssetSyncWatcher } from './mindspace-workspace-sync.mjs'; import { startWorkspaceAssetSyncWatcher } from './mindspace-workspace-sync.mjs';
import { attachRequestId, sendData, sendError } from './api-response.mjs'; import { attachRequestId, sendData, sendError } from './api-response.mjs';
@@ -689,6 +694,8 @@ api.use(createPortalApiAuthMiddleware({
getLegacySessionToken: legacySessionToken, getLegacySessionToken: legacySessionToken,
isPageDataPublicPath, isPageDataPublicPath,
isLegacyPageDataApiPath, isLegacyPageDataApiPath,
isProductAnalyticsPublicPath: (requestPath, method) =>
method === 'GET' && requestPath === '/analytics/context',
accessPolicyMode: portalAccessPolicyMode, accessPolicyMode: portalAccessPolicyMode,
accessEnforcementConfig: portalAccessEnforcementConfig, accessEnforcementConfig: portalAccessEnforcementConfig,
accessShadowReporter: portalAccessShadowReporter, accessShadowReporter: portalAccessShadowReporter,
@@ -699,6 +706,12 @@ attachPortalImageMakeRuntimeConfigRoute(api, {
getImageMakeAdminConfigService: () => imageMakeAdminConfigService, getImageMakeAdminConfigService: () => imageMakeAdminConfigService,
}); });
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 }); attachAsrRoutes(api, { sendError, sendData });
attachMindSpaceImageGenerationRoutes(api, { attachMindSpaceImageGenerationRoutes(api, {
getService: () => mindSpaceImageGeneration, getService: () => mindSpaceImageGeneration,
+22 -2
View File
@@ -21,6 +21,7 @@ export function createPortalApiAuthMiddleware({
getLegacySessionToken = () => null, getLegacySessionToken = () => null,
isPageDataPublicPath = () => false, isPageDataPublicPath = () => false,
isLegacyPageDataApiPath = () => false, isLegacyPageDataApiPath = () => false,
isProductAnalyticsPublicPath = () => false,
accessPolicyMode = PORTAL_ACCESS_POLICY_MODE.OFF, accessPolicyMode = PORTAL_ACCESS_POLICY_MODE.OFF,
accessEnforcementConfig = Object.freeze({ accessEnforcementConfig = Object.freeze({
masterEnabled: false, masterEnabled: false,
@@ -99,13 +100,24 @@ export function createPortalApiAuthMiddleware({
const plazaPublic = isPortalPlazaOptionalUserPath(req.path, req.method); const plazaPublic = isPortalPlazaOptionalUserPath(req.path, req.method);
const pageDataPublic = isPageDataPublicPath(req.path, req.method); const pageDataPublic = isPageDataPublicPath(req.path, req.method);
const productAnalyticsPublic = isProductAnalyticsPublicPath(
req.path,
req.method,
);
// The retired namespace must reach its explicit 410 route instead of // The retired namespace must reach its explicit 410 route instead of
// being converted into a misleading global 401/403 response. // being converted into a misleading global 401/403 response.
const legacyPageDataApi = isLegacyPageDataApiPath(req.path); const legacyPageDataApi = isLegacyPageDataApiPath(req.path);
if (userAuth && tkmindProxy) { if (userAuth && tkmindProxy) {
if (req.userSessionError) { if (req.userSessionError) {
if (plazaPublic || pageDataPublic || legacyPageDataApi) return next(); if (
plazaPublic ||
pageDataPublic ||
legacyPageDataApi ||
productAnalyticsPublic
) {
return next();
}
return res.status(503).json({ message: '用户认证服务不可用,请稍后重试' }); return res.status(503).json({ message: '用户认证服务不可用,请稍后重试' });
} }
try { try {
@@ -113,7 +125,14 @@ export function createPortalApiAuthMiddleware({
const me = await userAuth.getMe(req.userToken); const me = await userAuth.getMe(req.userToken);
if (me) req.currentUser = me; if (me) req.currentUser = me;
} }
if (plazaPublic || pageDataPublic || legacyPageDataApi) return next(); if (
plazaPublic ||
pageDataPublic ||
legacyPageDataApi ||
productAnalyticsPublic
) {
return next();
}
if (!req.userSession) { if (!req.userSession) {
return res.status(401).json({ message: '未授权,请重新登录' }); return res.status(401).json({ message: '未授权,请重新登录' });
} }
@@ -130,6 +149,7 @@ export function createPortalApiAuthMiddleware({
} }
} }
if (productAnalyticsPublic) return next();
if (legacyAuth?.verify(getLegacySessionToken(req))) return next(); if (legacyAuth?.verify(getLegacySessionToken(req))) return next();
return res.status(401).json({ message: '未授权,请重新登录' }); return res.status(401).json({ message: '未授权,请重新登录' });
}; };
@@ -57,6 +57,8 @@ function createMiddleware(overrides = {}) {
return createPortalApiAuthMiddleware({ return createPortalApiAuthMiddleware({
isPageDataPublicPath, isPageDataPublicPath,
isLegacyPageDataApiPath, isLegacyPageDataApiPath,
isProductAnalyticsPublicPath: (path, method) =>
method === 'GET' && path === '/analytics/context',
logger: createLogger(), logger: createLogger(),
...overrides, ...overrides,
}); });
@@ -143,6 +145,36 @@ test('multi-user public routes preserve optional user hydration', async () => {
assert.equal(getMeCalls, 1); assert.equal(getMeCalls, 1);
}); });
test('product analytics context preserves optional user hydration', async () => {
let getMeCalls = 0;
const user = { id: 'analytics-user' };
const middleware = createMiddleware({
getUserAuth: () => ({
async getMe(token) {
getMeCalls += 1;
assert.equal(token, 'user-token');
return user;
},
}),
getTkmindProxy: () => ({}),
});
const anonymous = await invoke(middleware, {
path: '/analytics/context',
userSession: null,
});
assert.equal(anonymous.nextCalls, 1);
assert.equal(anonymous.req.currentUser, undefined);
const authenticated = await invoke(middleware, {
path: '/analytics/context',
userSession: { id: 'session-analytics' },
});
assert.equal(authenticated.nextCalls, 1);
assert.equal(authenticated.req.currentUser, user);
assert.equal(getMeCalls, 1);
});
test('multi-user private routes preserve login, expiry, and double-check behavior', async () => { test('multi-user private routes preserve login, expiry, and double-check behavior', async () => {
let currentUser = { id: 'user-1' }; let currentUser = { id: 'user-1' };
let getMeCalls = 0; let getMeCalls = 0;
+2
View File
@@ -10,6 +10,7 @@ import { ChatProvider } from './context/ChatProvider';
import { PREVIEW_USER } from './dev/mindspacePreviewData'; import { PREVIEW_USER } from './dev/mindspacePreviewData';
import { MindSpaceRoute } from './routes/MindSpaceRoute'; import { MindSpaceRoute } from './routes/MindSpaceRoute';
import { FeedbackRoutes } from './routes/FeedbackRoute'; import { FeedbackRoutes } from './routes/FeedbackRoute';
import { useProductAnalytics } from './analytics/productAnalytics';
import type { CapabilityMap, PortalUser } from './types'; import type { CapabilityMap, PortalUser } from './types';
function isMindSpacePreview() { function isMindSpacePreview() {
@@ -124,6 +125,7 @@ export function App() {
const [grantedSkills, setGrantedSkills] = useState<string[] | undefined>(); const [grantedSkills, setGrantedSkills] = useState<string[] | undefined>();
const [legacyMode, setLegacyMode] = useState(false); const [legacyMode, setLegacyMode] = useState(false);
const [authUnavailable, setAuthUnavailable] = useState<string | null>(null); const [authUnavailable, setAuthUnavailable] = useState<string | null>(null);
useProductAnalytics(user?.id);
useEffect(() => { useEffect(() => {
if (mindSpacePreview) return; 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 <button
type="button" type="button"
className="header-icon-btn" className="header-icon-btn"
data-analytics-action="chat_new"
aria-label="新聊天" aria-label="新聊天"
title="新聊天" title="新聊天"
onClick={() => void handleNewSession()} onClick={() => void handleNewSession()}
@@ -386,7 +387,7 @@ export function ChatView({
</svg> </svg>
</button> </button>
{onOpenAdmin && ( {onOpenAdmin && (
<button type="button" className="ghost-btn" onClick={onOpenAdmin}> <button type="button" className="ghost-btn" data-analytics-action="open_admin" onClick={onOpenAdmin}>
</button> </button>
)} )}
@@ -395,6 +396,7 @@ export function ChatView({
ref={spaceButtonRef} ref={spaceButtonRef}
type="button" type="button"
className="ghost-btn" className="ghost-btn"
data-analytics-action="open_mindspace"
onClick={() => onOpenSpace?.()} onClick={() => onOpenSpace?.()}
> >
@@ -402,7 +404,7 @@ export function ChatView({
)} )}
{user && <WechatAccountButton returnTo={window.location.pathname} />} {user && <WechatAccountButton returnTo={window.location.pathname} />}
{onLogout && ( {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> </button>
)} )}
@@ -429,6 +431,7 @@ export function ChatView({
<button <button
type="button" type="button"
className="header-icon-btn" className="header-icon-btn"
data-analytics-action="chat_new"
aria-label="新聊天" aria-label="新聊天"
title="新聊天" title="新聊天"
onClick={() => void handleNewSession()} onClick={() => void handleNewSession()}
@@ -446,6 +449,7 @@ export function ChatView({
<button <button
type="button" type="button"
className="header-icon-btn" className="header-icon-btn"
data-analytics-action="open_mindspace"
aria-label="我的空间" aria-label="我的空间"
title="我的空间" title="我的空间"
onClick={() => onOpenSpace?.()} onClick={() => onOpenSpace?.()}