feat: add attributable page analytics
This commit is contained in:
@@ -18,6 +18,7 @@ H5_PUBLIC_BASE_URL=http://127.0.0.1:5173
|
|||||||
# MEMIND_ANALYTICS_URL=http://127.0.0.1:3100
|
# MEMIND_ANALYTICS_URL=http://127.0.0.1:3100
|
||||||
# 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_IDENTITY_MODE=pseudonymous # use raw only in an approved first-party analytics environment
|
||||||
# MEMIND_ANALYTICS_DOMAINS=127.0.0.1,localhost
|
# MEMIND_ANALYTICS_DOMAINS=127.0.0.1,localhost
|
||||||
# Memind 产品壳层使用独立 Website,避免与用户生成页面的浏览口径混在一起
|
# Memind 产品壳层使用独立 Website,避免与用户生成页面的浏览口径混在一起
|
||||||
# MEMIND_PRODUCT_ANALYTICS_ENABLED=true
|
# MEMIND_PRODUCT_ANALYTICS_ENABLED=true
|
||||||
|
|||||||
@@ -10,9 +10,14 @@ MEMIND_ANALYTICS_ENABLED=true
|
|||||||
MEMIND_ANALYTICS_URL=http://127.0.0.1:3100
|
MEMIND_ANALYTICS_URL=http://127.0.0.1:3100
|
||||||
MEMIND_ANALYTICS_WEBSITE_ID=<website-id>
|
MEMIND_ANALYTICS_WEBSITE_ID=<website-id>
|
||||||
MEMIND_ANALYTICS_ID_SECRET=<random-local-secret>
|
MEMIND_ANALYTICS_ID_SECRET=<random-local-secret>
|
||||||
|
MEMIND_ANALYTICS_IDENTITY_MODE=raw
|
||||||
MEMIND_ANALYTICS_DOMAINS=127.0.0.1,localhost
|
MEMIND_ANALYTICS_DOMAINS=127.0.0.1,localhost
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`MEMIND_ANALYTICS_IDENTITY_MODE=raw` sends the stable Memind user ID to an
|
||||||
|
explicitly approved first-party analytics service. Omit it elsewhere to keep the
|
||||||
|
pseudonymous default.
|
||||||
|
|
||||||
Create a second Umami Website for the Memind product shell. Keeping it separate
|
Create a second Umami Website for the Memind product shell. Keeping it separate
|
||||||
prevents chat, MindSpace, and feedback navigation from inflating generated-page
|
prevents chat, MindSpace, and feedback navigation from inflating generated-page
|
||||||
views. Do not create a Website per page or per user.
|
views. Do not create a Website per page or per user.
|
||||||
@@ -27,7 +32,7 @@ The product shell loads its tracker from the same-origin
|
|||||||
transition, `product_click` for links/buttons, and 10/30-second route engagement
|
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
|
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
|
copy arbitrary chat or generated-page text. Authenticated product events use the
|
||||||
same pseudonymous identity as generated pages while remaining in the separate
|
same configured identity as generated pages while remaining in the separate
|
||||||
product Website.
|
product Website.
|
||||||
|
|
||||||
## Rybbit (recommended for behavior analytics)
|
## Rybbit (recommended for behavior analytics)
|
||||||
|
|||||||
+32
-8
@@ -10,6 +10,9 @@ export function resolveMindSpaceAnalyticsConfig(env = process.env) {
|
|||||||
enabled: enabled && Boolean(websiteId) && Boolean(secret),
|
enabled: enabled && Boolean(websiteId) && Boolean(secret),
|
||||||
websiteId,
|
websiteId,
|
||||||
idSecret: secret,
|
idSecret: secret,
|
||||||
|
identityMode: String(env.MEMIND_ANALYTICS_IDENTITY_MODE ?? 'pseudonymous').trim().toLowerCase() === 'raw'
|
||||||
|
? 'raw'
|
||||||
|
: 'pseudonymous',
|
||||||
analyticsUrl: String(env.MEMIND_ANALYTICS_URL ?? 'http://127.0.0.1:3100').trim() || 'http://127.0.0.1:3100',
|
analyticsUrl: String(env.MEMIND_ANALYTICS_URL ?? 'http://127.0.0.1:3100').trim() || 'http://127.0.0.1:3100',
|
||||||
scriptPath: String(env.MEMIND_ANALYTICS_SCRIPT_PATH ?? '/analytics/script.js').trim() || '/analytics/script.js',
|
scriptPath: String(env.MEMIND_ANALYTICS_SCRIPT_PATH ?? '/analytics/script.js').trim() || '/analytics/script.js',
|
||||||
hostPath: String(env.MEMIND_ANALYTICS_HOST_PATH ?? '/analytics').trim() || '/analytics',
|
hostPath: String(env.MEMIND_ANALYTICS_HOST_PATH ?? '/analytics').trim() || '/analytics',
|
||||||
@@ -27,6 +30,7 @@ export function resolveProductAnalyticsConfig(
|
|||||||
enabled: enabled && Boolean(websiteId) && Boolean(baseConfig?.idSecret),
|
enabled: enabled && Boolean(websiteId) && Boolean(baseConfig?.idSecret),
|
||||||
websiteId,
|
websiteId,
|
||||||
idSecret: String(baseConfig?.idSecret ?? '').trim(),
|
idSecret: String(baseConfig?.idSecret ?? '').trim(),
|
||||||
|
identityMode: baseConfig?.identityMode === 'raw' ? 'raw' : 'pseudonymous',
|
||||||
scriptPath: String(baseConfig?.scriptPath ?? '/analytics/script.js').trim() || '/analytics/script.js',
|
scriptPath: String(baseConfig?.scriptPath ?? '/analytics/script.js').trim() || '/analytics/script.js',
|
||||||
hostPath: String(baseConfig?.hostPath ?? '/analytics').trim() || '/analytics',
|
hostPath: String(baseConfig?.hostPath ?? '/analytics').trim() || '/analytics',
|
||||||
domains: String(baseConfig?.domains ?? '').trim(),
|
domains: String(baseConfig?.domains ?? '').trim(),
|
||||||
@@ -40,12 +44,25 @@ export function pseudonymizeAnalyticsId(value, secret) {
|
|||||||
return crypto.createHmac('sha256', key).update(normalized).digest('hex').slice(0, 32);
|
return crypto.createHmac('sha256', key).update(normalized).digest('hex').slice(0, 32);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function resolveAnalyticsIdentity(value, config = {}) {
|
||||||
|
const normalized = String(value ?? '').trim().replace(/[\r\n\t]+/g, '').slice(0, 128);
|
||||||
|
if (!normalized) return '';
|
||||||
|
return config?.identityMode === 'raw'
|
||||||
|
? normalized
|
||||||
|
: pseudonymizeAnalyticsId(normalized, config?.idSecret);
|
||||||
|
}
|
||||||
|
|
||||||
export function resolveAnalyticsOwnerSegment(user = {}) {
|
export function resolveAnalyticsOwnerSegment(user = {}) {
|
||||||
if (user?.role === 'admin') return 'admin';
|
if (user?.role === 'admin') return 'admin';
|
||||||
const plan = String(user?.planType ?? user?.plan_type ?? 'free').trim().toLowerCase();
|
const plan = String(user?.planType ?? user?.plan_type ?? 'free').trim().toLowerCase();
|
||||||
return `plan:${plan || 'free'}`;
|
return `plan:${plan || 'free'}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function resolveAnalyticsPlan(user = {}) {
|
||||||
|
if (user?.role === 'admin') return 'admin';
|
||||||
|
return String(user?.planType ?? user?.plan_type ?? 'free').trim().toLowerCase() || 'free';
|
||||||
|
}
|
||||||
|
|
||||||
export function resolveAnalyticsOwnerLabel(user = {}) {
|
export function resolveAnalyticsOwnerLabel(user = {}) {
|
||||||
const label = String(user?.displayName ?? user?.display_name ?? user?.username ?? '').trim();
|
const label = String(user?.displayName ?? user?.display_name ?? user?.username ?? '').trim();
|
||||||
return label.replace(/[\r\n\t]+/g, ' ').slice(0, 80) || '未命名用户';
|
return label.replace(/[\r\n\t]+/g, ' ').slice(0, 80) || '未命名用户';
|
||||||
@@ -53,7 +70,7 @@ export function resolveAnalyticsOwnerLabel(user = {}) {
|
|||||||
|
|
||||||
export function buildProductAnalyticsContext({ config, user = null } = {}) {
|
export function buildProductAnalyticsContext({ config, user = null } = {}) {
|
||||||
if (!config?.enabled || !config.websiteId) return { enabled: false };
|
if (!config?.enabled || !config.websiteId) return { enabled: false };
|
||||||
const distinctId = user?.id ? pseudonymizeAnalyticsId(user.id, config.idSecret) : '';
|
const distinctId = user?.id ? resolveAnalyticsIdentity(user.id, config) : '';
|
||||||
return {
|
return {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
websiteId: config.websiteId,
|
websiteId: config.websiteId,
|
||||||
@@ -65,8 +82,10 @@ export function buildProductAnalyticsContext({ config, user = null } = {}) {
|
|||||||
distinctId,
|
distinctId,
|
||||||
username: resolveAnalyticsOwnerLabel(user),
|
username: resolveAnalyticsOwnerLabel(user),
|
||||||
ownerSegment: resolveAnalyticsOwnerSegment(user),
|
ownerSegment: resolveAnalyticsOwnerSegment(user),
|
||||||
|
planType: resolveAnalyticsPlan(user),
|
||||||
channel: 'h5',
|
channel: 'h5',
|
||||||
surface: 'product',
|
surface: 'product',
|
||||||
|
identityMode: config.identityMode === 'raw' ? 'raw' : 'pseudonymous',
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
};
|
};
|
||||||
@@ -82,14 +101,17 @@ export function sendMindSpaceAnalyticsEvent({
|
|||||||
channel = 'h5',
|
channel = 'h5',
|
||||||
ownerSegment = 'unknown',
|
ownerSegment = 'unknown',
|
||||||
ownerLabel = '未命名用户',
|
ownerLabel = '未命名用户',
|
||||||
|
planType = 'unknown',
|
||||||
|
generatedAt = '',
|
||||||
url = '',
|
url = '',
|
||||||
} = {}) {
|
} = {}) {
|
||||||
if (!config?.enabled || !config.websiteId || !config.idSecret || !eventName) return Promise.resolve(false);
|
if (!config?.enabled || !config.websiteId || !config.idSecret || !eventName) return Promise.resolve(false);
|
||||||
const owner = pseudonymizeAnalyticsId(ownerId, config.idSecret);
|
const owner = resolveAnalyticsIdentity(ownerId, config);
|
||||||
if (!owner) return Promise.resolve(false);
|
if (!owner) return Promise.resolve(false);
|
||||||
const endpoint = `${String(config.analyticsUrl || 'http://127.0.0.1:3100').replace(/\/$/, '')}/api/send`;
|
const endpoint = `${String(config.analyticsUrl || 'http://127.0.0.1:3100').replace(/\/$/, '')}/api/send`;
|
||||||
const payload = {
|
const payload = {
|
||||||
website: config.websiteId,
|
website: config.websiteId,
|
||||||
|
id: owner,
|
||||||
hostname: '127.0.0.1',
|
hostname: '127.0.0.1',
|
||||||
url: url || '/',
|
url: url || '/',
|
||||||
name: String(eventName),
|
name: String(eventName),
|
||||||
@@ -100,7 +122,10 @@ export function sendMindSpaceAnalyticsEvent({
|
|||||||
agent_run_id: String(agentRunId || ''),
|
agent_run_id: String(agentRunId || ''),
|
||||||
channel,
|
channel,
|
||||||
owner_segment: String(ownerSegment || 'unknown'),
|
owner_segment: String(ownerSegment || 'unknown'),
|
||||||
|
plan_type: String(planType || 'unknown'),
|
||||||
owner_label: resolveAnalyticsOwnerLabel({ displayName: ownerLabel }),
|
owner_label: resolveAnalyticsOwnerLabel({ displayName: ownerLabel }),
|
||||||
|
generated_at: String(generatedAt || ''),
|
||||||
|
identity_mode: config.identityMode === 'raw' ? 'raw' : 'pseudonymous',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
return fetch(endpoint, {
|
return fetch(endpoint, {
|
||||||
@@ -124,18 +149,17 @@ export function injectMindSpaceAnalytics(html, {
|
|||||||
publicationId = '',
|
publicationId = '',
|
||||||
ownerSegment = 'unknown',
|
ownerSegment = 'unknown',
|
||||||
ownerLabel = '未命名用户',
|
ownerLabel = '未命名用户',
|
||||||
|
planType = 'unknown',
|
||||||
|
generatedAt = '',
|
||||||
channel = 'h5',
|
channel = 'h5',
|
||||||
config = resolveMindSpaceAnalyticsConfig(),
|
config = resolveMindSpaceAnalyticsConfig(),
|
||||||
} = {}) {
|
} = {}) {
|
||||||
const source = String(html ?? '');
|
const source = String(html ?? '');
|
||||||
if (!config?.enabled || !config.websiteId || !/^\s*(<!doctype html|<html\b)/i.test(source)) return source;
|
if (!config?.enabled || !config.websiteId || !/^\s*(<!doctype html|<html\b)/i.test(source)) return source;
|
||||||
if (source.includes(ANALYTICS_MARKER)) return source;
|
if (source.includes(ANALYTICS_MARKER)) return source;
|
||||||
const owner = pseudonymizeAnalyticsId(ownerId, config.idSecret);
|
const owner = resolveAnalyticsIdentity(ownerId, config);
|
||||||
if (!owner) return source;
|
if (!owner) return source;
|
||||||
// The stable pseudonym remains the Umami identity key. The readable username
|
const metadata = { owner_id: owner, username: resolveAnalyticsOwnerLabel({ displayName: ownerLabel }), owner_segment: String(ownerSegment || 'unknown'), plan_type: String(planType || 'unknown'), page_id: String(pageId || ''), publication_id: String(publicationId || ''), generated_at: String(generatedAt || ''), identity_mode: config.identityMode === 'raw' ? 'raw' : 'pseudonymous', channel, surface: 'generated_page' };
|
||||||
// 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, surface: 'generated_page' };
|
|
||||||
const attrs = [
|
const attrs = [
|
||||||
ANALYTICS_MARKER,
|
ANALYTICS_MARKER,
|
||||||
`data-website-id="${config.websiteId.replaceAll('"', '"')}"`,
|
`data-website-id="${config.websiteId.replaceAll('"', '"')}"`,
|
||||||
@@ -143,7 +167,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 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>`;
|
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,plan_type:d.plan_type,channel:d.channel,surface:d.surface,identity_mode:d.identity_mode});}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`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import {
|
|||||||
buildProductAnalyticsContext,
|
buildProductAnalyticsContext,
|
||||||
injectMindSpaceAnalytics,
|
injectMindSpaceAnalytics,
|
||||||
pseudonymizeAnalyticsId,
|
pseudonymizeAnalyticsId,
|
||||||
|
resolveAnalyticsIdentity,
|
||||||
|
resolveAnalyticsPlan,
|
||||||
resolveAnalyticsOwnerSegment,
|
resolveAnalyticsOwnerSegment,
|
||||||
resolveAnalyticsOwnerLabel,
|
resolveAnalyticsOwnerLabel,
|
||||||
resolveMindSpaceAnalyticsConfig,
|
resolveMindSpaceAnalyticsConfig,
|
||||||
@@ -34,8 +36,10 @@ test('product analytics uses a separate website and the existing pseudonymizatio
|
|||||||
distinctId: pseudonymizeAnalyticsId('user-123', 'local-secret'),
|
distinctId: pseudonymizeAnalyticsId('user-123', 'local-secret'),
|
||||||
username: '张三',
|
username: '张三',
|
||||||
ownerSegment: 'plan:pro',
|
ownerSegment: 'plan:pro',
|
||||||
|
planType: 'pro',
|
||||||
channel: 'h5',
|
channel: 'h5',
|
||||||
surface: 'product',
|
surface: 'product',
|
||||||
|
identityMode: 'pseudonymous',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -79,10 +83,18 @@ test('owner ids are stable pseudonyms and never expose the source id', () => {
|
|||||||
assert.notEqual(first, pseudonymizeAnalyticsId('user-456', 'secret'));
|
assert.notEqual(first, pseudonymizeAnalyticsId('user-456', 'secret'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('approved raw identity mode preserves the stable Memind user id', () => {
|
||||||
|
const config = { identityMode: 'raw', idSecret: 'unused' };
|
||||||
|
assert.equal(resolveAnalyticsIdentity('user-123', config), 'user-123');
|
||||||
|
assert.equal(resolveAnalyticsIdentity(' user-123\n', config), 'user-123');
|
||||||
|
});
|
||||||
|
|
||||||
test('owner segments come from server-side Memind user profile data', () => {
|
test('owner segments come from server-side Memind user profile data', () => {
|
||||||
assert.equal(resolveAnalyticsOwnerSegment({ role: 'admin' }), 'admin');
|
assert.equal(resolveAnalyticsOwnerSegment({ role: 'admin' }), 'admin');
|
||||||
assert.equal(resolveAnalyticsOwnerSegment({ role: 'user', planType: 'pro' }), 'plan:pro');
|
assert.equal(resolveAnalyticsOwnerSegment({ role: 'user', planType: 'pro' }), 'plan:pro');
|
||||||
assert.equal(resolveAnalyticsOwnerSegment({ role: 'user' }), 'plan:free');
|
assert.equal(resolveAnalyticsOwnerSegment({ role: 'user' }), 'plan:free');
|
||||||
|
assert.equal(resolveAnalyticsPlan({ role: 'user', planType: 'pro' }), 'pro');
|
||||||
|
assert.equal(resolveAnalyticsPlan({ role: 'user' }), 'free');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('owner labels are readable but bounded and stripped of control characters', () => {
|
test('owner labels are readable but bounded and stripped of control characters', () => {
|
||||||
@@ -97,6 +109,8 @@ test('injects one local same-origin tracker with page dimensions', () => {
|
|||||||
ownerLabel: '张三',
|
ownerLabel: '张三',
|
||||||
pageId: 'page-1',
|
pageId: 'page-1',
|
||||||
publicationId: 'pub-1',
|
publicationId: 'pub-1',
|
||||||
|
planType: 'pro',
|
||||||
|
generatedAt: '2026-07-20T01:02:03.000Z',
|
||||||
config: {
|
config: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
websiteId: 'local-website',
|
websiteId: 'local-website',
|
||||||
@@ -104,12 +118,13 @@ test('injects one local same-origin tracker with page dimensions', () => {
|
|||||||
scriptPath: '/analytics/script.js',
|
scriptPath: '/analytics/script.js',
|
||||||
hostPath: '/analytics',
|
hostPath: '/analytics',
|
||||||
domains: '127.0.0.1,localhost',
|
domains: '127.0.0.1,localhost',
|
||||||
|
identityMode: 'raw',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
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,surface:d\.surface\}\)/);
|
assert.match(out, /window\.umami\.identify\(d\.owner_id,\{username:d\.username,memind_page_url:location\.href,owner_segment:d\.owner_segment,plan_type:d\.plan_type,channel:d\.channel,surface:d\.surface,identity_mode:d\.identity_mode\}\)/);
|
||||||
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'\)/);
|
||||||
@@ -123,7 +138,8 @@ test('injects one local same-origin tracker with page dimensions', () => {
|
|||||||
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/);
|
||||||
assert.doesNotMatch(out, /user-123/);
|
assert.match(out, /"owner_id":"user-123"/);
|
||||||
|
assert.match(out, /"generated_at":"2026-07-20T01:02:03.000Z"/);
|
||||||
assert.equal(injectMindSpaceAnalytics(out, { ownerId: 'user-123', config: { enabled: true, websiteId: 'local-website', idSecret: 'secret' } }), out);
|
assert.equal(injectMindSpaceAnalytics(out, { ownerId: 'user-123', config: { enabled: true, websiteId: 'local-website', idSecret: 'secret' } }), out);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -172,6 +188,8 @@ test('identifies the pseudonymous owner before sending a standard page view', ()
|
|||||||
owner_segment: 'plan:pro',
|
owner_segment: 'plan:pro',
|
||||||
channel: 'h5',
|
channel: 'h5',
|
||||||
surface: 'generated_page',
|
surface: 'generated_page',
|
||||||
|
plan_type: 'unknown',
|
||||||
|
identity_mode: 'pseudonymous',
|
||||||
}],
|
}],
|
||||||
['track'],
|
['track'],
|
||||||
]);
|
]);
|
||||||
@@ -187,3 +205,50 @@ test('does not alter non-full-html or disabled pages', () => {
|
|||||||
test('analytics event sender is fail-open when analytics is disabled', async () => {
|
test('analytics event sender is fail-open when analytics is disabled', async () => {
|
||||||
assert.equal(await sendMindSpaceAnalyticsEvent({ eventName: 'page_generated', ownerId: 'u', config: { enabled: false } }), false);
|
assert.equal(await sendMindSpaceAnalyticsEvent({ eventName: 'page_generated', ownerId: 'u', config: { enabled: false } }), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('generation events are attributed to raw identity and include analysis dimensions', async () => {
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
let requestBody;
|
||||||
|
globalThis.fetch = async (_url, init) => {
|
||||||
|
requestBody = JSON.parse(init.body);
|
||||||
|
return { ok: true };
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
assert.equal(await sendMindSpaceAnalyticsEvent({
|
||||||
|
config: {
|
||||||
|
enabled: true,
|
||||||
|
websiteId: 'generated-pages',
|
||||||
|
idSecret: 'secret',
|
||||||
|
identityMode: 'raw',
|
||||||
|
analyticsUrl: 'http://127.0.0.1:3100',
|
||||||
|
},
|
||||||
|
eventName: 'page_generated',
|
||||||
|
ownerId: 'user-123',
|
||||||
|
ownerLabel: '张三',
|
||||||
|
ownerSegment: 'plan:pro',
|
||||||
|
planType: 'pro',
|
||||||
|
pageId: 'page-123',
|
||||||
|
generatedAt: '2026-07-20T01:02:03.000Z',
|
||||||
|
}), true);
|
||||||
|
assert.equal(requestBody.payload.id, 'user-123');
|
||||||
|
assert.deepEqual(requestBody.payload.data, expectPayload({
|
||||||
|
owner_id: 'user-123',
|
||||||
|
owner_label: '张三',
|
||||||
|
owner_segment: 'plan:pro',
|
||||||
|
plan_type: 'pro',
|
||||||
|
page_id: 'page-123',
|
||||||
|
generated_at: '2026-07-20T01:02:03.000Z',
|
||||||
|
identity_mode: 'raw',
|
||||||
|
}));
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function expectPayload(expected) {
|
||||||
|
return Object.assign({
|
||||||
|
publication_id: '',
|
||||||
|
agent_run_id: '',
|
||||||
|
channel: 'h5',
|
||||||
|
}, expected);
|
||||||
|
}
|
||||||
|
|||||||
@@ -150,6 +150,9 @@ export async function resolveMindSpacePageDataContext({
|
|||||||
pageId: page.id,
|
pageId: page.id,
|
||||||
accessMode: page.publicationAccessMode ?? null,
|
accessMode: page.publicationAccessMode ?? null,
|
||||||
publicationId: page.currentPublishId ?? page.current_publish_id ?? null,
|
publicationId: page.currentPublishId ?? page.current_publish_id ?? null,
|
||||||
|
...(page.createdAt
|
||||||
|
? { generatedAt: new Date(page.createdAt).toISOString() }
|
||||||
|
: {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -98,6 +98,7 @@ test('resolveMindSpacePageDataContext falls back to the MindSpace page service',
|
|||||||
id: 'remote-page',
|
id: 'remote-page',
|
||||||
publicationAccessMode: 'internal',
|
publicationAccessMode: 'internal',
|
||||||
currentPublishId: 'remote-publish',
|
currentPublishId: 'remote-publish',
|
||||||
|
createdAt: '2026-07-20T12:34:56.000Z',
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -108,5 +109,6 @@ test('resolveMindSpacePageDataContext falls back to the MindSpace page service',
|
|||||||
pageId: 'remote-page',
|
pageId: 'remote-page',
|
||||||
accessMode: 'internal',
|
accessMode: 'internal',
|
||||||
publicationId: 'remote-publish',
|
publicationId: 'remote-publish',
|
||||||
|
generatedAt: '2026-07-20T12:34:56.000Z',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
resolveAnalyticsOwnerLabel,
|
resolveAnalyticsOwnerLabel,
|
||||||
resolveAnalyticsOwnerSegment,
|
resolveAnalyticsOwnerSegment,
|
||||||
|
resolveAnalyticsPlan,
|
||||||
sendMindSpaceAnalyticsEvent,
|
sendMindSpaceAnalyticsEvent,
|
||||||
} from '../mindspace-analytics.mjs';
|
} from '../mindspace-analytics.mjs';
|
||||||
import {
|
import {
|
||||||
@@ -43,6 +44,7 @@ export async function bootstrapPortalIntegrationServices({
|
|||||||
resolveAnalyticsOwnerSegment,
|
resolveAnalyticsOwnerSegment,
|
||||||
resolveAnalyticsOwnerLabelFn =
|
resolveAnalyticsOwnerLabelFn =
|
||||||
resolveAnalyticsOwnerLabel,
|
resolveAnalyticsOwnerLabel,
|
||||||
|
resolveAnalyticsPlanFn = resolveAnalyticsPlan,
|
||||||
sendMindSpaceAnalyticsEventFn =
|
sendMindSpaceAnalyticsEventFn =
|
||||||
sendMindSpaceAnalyticsEvent,
|
sendMindSpaceAnalyticsEvent,
|
||||||
sendMindSpaceRybbitEventFn =
|
sendMindSpaceRybbitEventFn =
|
||||||
@@ -155,6 +157,8 @@ export async function bootstrapPortalIntegrationServices({
|
|||||||
ownerLabel: resolveAnalyticsOwnerLabelFn(
|
ownerLabel: resolveAnalyticsOwnerLabelFn(
|
||||||
pageOwner,
|
pageOwner,
|
||||||
),
|
),
|
||||||
|
planType: resolveAnalyticsPlanFn(pageOwner),
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
pageId: artifact.relativePath,
|
pageId: artifact.relativePath,
|
||||||
publicationId: sessionId,
|
publicationId: sessionId,
|
||||||
agentRunId: sessionId,
|
agentRunId: sessionId,
|
||||||
|
|||||||
@@ -148,6 +148,10 @@ function createSetup(overrides = {}) {
|
|||||||
calls.push(['owner-label', user]);
|
calls.push(['owner-label', user]);
|
||||||
return 'label';
|
return 'label';
|
||||||
},
|
},
|
||||||
|
resolveAnalyticsPlanFn(user) {
|
||||||
|
calls.push(['plan-type', user]);
|
||||||
|
return 'pro';
|
||||||
|
},
|
||||||
sendMindSpaceAnalyticsEventFn(event) {
|
sendMindSpaceAnalyticsEventFn(event) {
|
||||||
calls.push(['analytics', event]);
|
calls.push(['analytics', event]);
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
@@ -349,12 +353,18 @@ test('preserves generated-page analytics projection', async () => {
|
|||||||
ownerId: 'user-1',
|
ownerId: 'user-1',
|
||||||
ownerSegment: 'segment',
|
ownerSegment: 'segment',
|
||||||
ownerLabel: 'label',
|
ownerLabel: 'label',
|
||||||
|
planType: 'pro',
|
||||||
|
generatedAt: analyticsCall[1].generatedAt,
|
||||||
pageId: 'public/page.html',
|
pageId: 'public/page.html',
|
||||||
publicationId: 'session-1',
|
publicationId: 'session-1',
|
||||||
agentRunId: 'session-1',
|
agentRunId: 'session-1',
|
||||||
channel: 'wechat_mp',
|
channel: 'wechat_mp',
|
||||||
url: 'https://example/page',
|
url: 'https://example/page',
|
||||||
});
|
});
|
||||||
|
assert.match(
|
||||||
|
analyticsCall[1].generatedAt,
|
||||||
|
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/,
|
||||||
|
);
|
||||||
const rybbitCall = setup.calls.find(
|
const rybbitCall = setup.calls.find(
|
||||||
([name]) => name === 'rybbit',
|
([name]) => name === 'rybbit',
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
resolveAnalyticsOwnerLabel,
|
resolveAnalyticsOwnerLabel,
|
||||||
resolveAnalyticsOwnerSegment,
|
resolveAnalyticsOwnerSegment,
|
||||||
|
resolveAnalyticsPlan,
|
||||||
sendMindSpaceAnalyticsEvent,
|
sendMindSpaceAnalyticsEvent,
|
||||||
} from '../mindspace-analytics.mjs';
|
} from '../mindspace-analytics.mjs';
|
||||||
import {
|
import {
|
||||||
@@ -96,6 +97,7 @@ export function attachPortalSessionRoutes(
|
|||||||
resolveAnalyticsOwnerSegment,
|
resolveAnalyticsOwnerSegment,
|
||||||
resolveAnalyticsOwnerLabelFn =
|
resolveAnalyticsOwnerLabelFn =
|
||||||
resolveAnalyticsOwnerLabel,
|
resolveAnalyticsOwnerLabel,
|
||||||
|
resolveAnalyticsPlanFn = resolveAnalyticsPlan,
|
||||||
syncPublicHtmlAfterFinishFn = syncPublicHtmlAfterFinish,
|
syncPublicHtmlAfterFinishFn = syncPublicHtmlAfterFinish,
|
||||||
resolveMindSpaceRuntimeConfigFn =
|
resolveMindSpaceRuntimeConfigFn =
|
||||||
resolveMindSpaceRuntimeConfig,
|
resolveMindSpaceRuntimeConfig,
|
||||||
@@ -432,6 +434,8 @@ export function attachPortalSessionRoutes(
|
|||||||
resolveAnalyticsOwnerSegmentFn(req.currentUser),
|
resolveAnalyticsOwnerSegmentFn(req.currentUser),
|
||||||
ownerLabel:
|
ownerLabel:
|
||||||
resolveAnalyticsOwnerLabelFn(req.currentUser),
|
resolveAnalyticsOwnerLabelFn(req.currentUser),
|
||||||
|
planType: resolveAnalyticsPlanFn(req.currentUser),
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
pageId: relativePath,
|
pageId: relativePath,
|
||||||
publicationId: sessionId,
|
publicationId: sessionId,
|
||||||
agentRunId: sessionId,
|
agentRunId: sessionId,
|
||||||
|
|||||||
@@ -165,6 +165,7 @@ function createDependencies(overrides = {}) {
|
|||||||
sendMindSpaceAnalyticsEventFn: async () => {},
|
sendMindSpaceAnalyticsEventFn: async () => {},
|
||||||
resolveAnalyticsOwnerSegmentFn: () => 'segment',
|
resolveAnalyticsOwnerSegmentFn: () => 'segment',
|
||||||
resolveAnalyticsOwnerLabelFn: () => 'label',
|
resolveAnalyticsOwnerLabelFn: () => 'label',
|
||||||
|
resolveAnalyticsPlanFn: () => 'pro',
|
||||||
syncPublicHtmlAfterFinishFn: async () => ({
|
syncPublicHtmlAfterFinishFn: async () => ({
|
||||||
publicHtmlRelativePaths: [],
|
publicHtmlRelativePaths: [],
|
||||||
}),
|
}),
|
||||||
@@ -524,6 +525,11 @@ test('stream hook uses current session id for contracts and analytics', async ()
|
|||||||
assert.equal(calls[1].kind, 'materialize');
|
assert.equal(calls[1].kind, 'materialize');
|
||||||
assert.equal(calls[2].input.publicationId, 'session-1');
|
assert.equal(calls[2].input.publicationId, 'session-1');
|
||||||
assert.equal(calls[2].input.agentRunId, 'session-1');
|
assert.equal(calls[2].input.agentRunId, 'session-1');
|
||||||
|
assert.equal(calls[2].input.planType, 'pro');
|
||||||
|
assert.match(
|
||||||
|
calls[2].input.generatedAt,
|
||||||
|
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Finish hook preserves refresh, sync, delivery readiness, memory, and lock lifecycle', async () => {
|
test('Finish hook preserves refresh, sync, delivery readiness, memory, and lock lifecycle', async () => {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
injectMindSpaceAnalytics,
|
injectMindSpaceAnalytics,
|
||||||
resolveAnalyticsOwnerLabel,
|
resolveAnalyticsOwnerLabel,
|
||||||
resolveAnalyticsOwnerSegment,
|
resolveAnalyticsOwnerSegment,
|
||||||
|
resolveAnalyticsPlan,
|
||||||
} from '../mindspace-analytics.mjs';
|
} from '../mindspace-analytics.mjs';
|
||||||
import {
|
import {
|
||||||
injectMindSpaceRybbit,
|
injectMindSpaceRybbit,
|
||||||
@@ -303,6 +304,8 @@ export function createPortalWorkspacePublicationDelivery({
|
|||||||
ownerLabel: resolveAnalyticsOwnerLabel(
|
ownerLabel: resolveAnalyticsOwnerLabel(
|
||||||
pageOwner ?? {},
|
pageOwner ?? {},
|
||||||
),
|
),
|
||||||
|
planType: resolveAnalyticsPlan(pageOwner ?? {}),
|
||||||
|
generatedAt: pageDataContext?.generatedAt ?? '',
|
||||||
pageId:
|
pageId:
|
||||||
pageDataContext?.pageId ?? '',
|
pageDataContext?.pageId ?? '',
|
||||||
publicationId:
|
publicationId:
|
||||||
|
|||||||
@@ -5,8 +5,10 @@ type AnalyticsIdentity = {
|
|||||||
distinctId: string;
|
distinctId: string;
|
||||||
username: string;
|
username: string;
|
||||||
ownerSegment: string;
|
ownerSegment: string;
|
||||||
|
planType: string;
|
||||||
channel: string;
|
channel: string;
|
||||||
surface: 'product';
|
surface: 'product';
|
||||||
|
identityMode: 'raw' | 'pseudonymous';
|
||||||
};
|
};
|
||||||
|
|
||||||
type ProductAnalyticsContext = {
|
type ProductAnalyticsContext = {
|
||||||
@@ -160,6 +162,8 @@ function trackProductEvent(
|
|||||||
route_name: resolveProductRouteName(window.location.pathname),
|
route_name: resolveProductRouteName(window.location.pathname),
|
||||||
channel: identity?.channel || 'h5',
|
channel: identity?.channel || 'h5',
|
||||||
owner_segment: identity?.ownerSegment || 'anonymous',
|
owner_segment: identity?.ownerSegment || 'anonymous',
|
||||||
|
plan_type: identity?.planType || 'anonymous',
|
||||||
|
identity_mode: identity?.identityMode || 'anonymous',
|
||||||
...data,
|
...data,
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
@@ -203,8 +207,10 @@ export function useProductAnalytics(userId?: string | null) {
|
|||||||
void window.umami?.identify(identity.distinctId, {
|
void window.umami?.identify(identity.distinctId, {
|
||||||
username: identity.username,
|
username: identity.username,
|
||||||
owner_segment: identity.ownerSegment,
|
owner_segment: identity.ownerSegment,
|
||||||
|
plan_type: identity.planType,
|
||||||
channel: identity.channel,
|
channel: identity.channel,
|
||||||
surface: identity.surface,
|
surface: identity.surface,
|
||||||
|
identity_mode: identity.identityMode,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else if (lastIdentityKey) {
|
} else if (lastIdentityKey) {
|
||||||
|
|||||||
Reference in New Issue
Block a user