feat: track Memind product analytics
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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('"', '"')}"`,
|
`data-website-id="${config.websiteId.replaceAll('"', '"')}"`,
|
||||||
@@ -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('"', '"')}"`);
|
if (config.domains) attrs.push(`data-domains="${config.domains.replaceAll('"', '"')}"`);
|
||||||
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`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
}
|
||||||
@@ -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?.()}
|
||||||
|
|||||||
Reference in New Issue
Block a user