feat: add attributable page analytics

This commit is contained in:
john
2026-07-20 20:49:44 +08:00
parent 175095cbf3
commit a4522854d8
6 changed files with 123 additions and 18 deletions
+1
View File
@@ -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
+9 -7
View File
@@ -14,7 +14,7 @@ local Umami service at `http://127.0.0.1:3100`; it does not contact 103/105.
navigation from inflating generated-page views. Do not create a Website per navigation from inflating generated-page views. Do not create a Website per
page or per user. page or per user.
3. Put the Website ID and a local-only pseudonymization secret in Memind's 3. Put the Website ID and a local-only identity secret in Memind's
`.env`: `.env`:
```dotenv ```dotenv
@@ -22,6 +22,7 @@ local Umami service at `http://127.0.0.1:3100`; it does not contact 103/105.
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_PRODUCT_ANALYTICS_ENABLED=true MEMIND_PRODUCT_ANALYTICS_ENABLED=true
MEMIND_PRODUCT_ANALYTICS_WEBSITE_ID=<product-website-id> MEMIND_PRODUCT_ANALYTICS_WEBSITE_ID=<product-website-id>
@@ -29,18 +30,19 @@ local Umami service at `http://127.0.0.1:3100`; it does not contact 103/105.
4. Restart the local Memind server. Full generated HTML pages will receive a 4. Restart the local Memind server. Full generated HTML pages will receive a
same-origin `/analytics/script.js` tracker. The tracker identifies the same-origin `/analytics/script.js` tracker. The tracker identifies the
visitor with a stable pseudonymous owner ID before sending a standard Umami visitor with the stable Memind user ID when `IDENTITY_MODE=raw` before
page view, so Users and Pageviews are populated. The Identify properties sending a standard Umami page view, so Users and Pageviews are populated.
include the readable Memind username and current public page URL for The Identify properties include username, plan, channel, identity mode and
operational analytics. Click, form, scroll, and engagement events retain the current public page URL. Generated-page events also retain `page_id` and the
pseudonymous `owner_id`, `page_id`, and `channel` dimensions. precise `generated_at` timestamp. Omit the identity-mode setting outside an
explicitly approved first-party environment to keep the pseudonymous default.
5. The product shell loads its tracker from the same-origin 5. The product shell loads its tracker from the same-origin
`/analytics/script.js` endpoint. It records one standard page view per SPA `/analytics/script.js` endpoint. It records one standard page view per SPA
route transition, `product_click` for links/buttons, and 10/30-second route route transition, `product_click` for links/buttons, and 10/30-second route
engagement events. Query strings are restricted to a small allowlist and engagement events. Query strings are restricted to a small allowlist and
click labels never copy arbitrary chat or generated-page text. Authenticated click labels never copy arbitrary chat or generated-page text. Authenticated
product events use the same pseudonymous identity as generated pages, while product events use the same configured identity as generated pages, while
remaining in the separate product Website. remaining in the separate product Website.
The integration is fail-open: missing configuration, disabled analytics, or a The integration is fail-open: missing configuration, disabled analytics, or a
+32 -8
View File
@@ -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('"', '&quot;')}"`, `data-website-id="${config.websiteId.replaceAll('"', '&quot;')}"`,
@@ -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('"', '&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 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`);
} }
+67 -2
View File
@@ -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);
}
+8 -1
View File
@@ -42,7 +42,7 @@ 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, PUBLISH_KEY_UUID, PUBLIC_ZONE_DIR } from './user-publish.mjs'; import { PUBLISH_ROOT_DIR, PUBLISH_KEY_UUID, PUBLIC_ZONE_DIR } from './user-publish.mjs';
import { ensureWorkspaceHtmlThumbnail, startWorkspaceThumbnailWatcher, workspaceThumbnailRelativePath } from './mindspace-workspace-thumbnails.mjs'; import { ensureWorkspaceHtmlThumbnail, startWorkspaceThumbnailWatcher, workspaceThumbnailRelativePath } from './mindspace-workspace-thumbnails.mjs';
import { buildProductAnalyticsContext, injectMindSpaceAnalytics, resolveAnalyticsOwnerLabel, resolveAnalyticsOwnerSegment, resolveMindSpaceAnalyticsConfig, resolveProductAnalyticsConfig, sendMindSpaceAnalyticsEvent } from './mindspace-analytics.mjs'; import { buildProductAnalyticsContext, injectMindSpaceAnalytics, resolveAnalyticsOwnerLabel, resolveAnalyticsOwnerSegment, resolveAnalyticsPlan, resolveMindSpaceAnalyticsConfig, resolveProductAnalyticsConfig, sendMindSpaceAnalyticsEvent } from './mindspace-analytics.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';
import { createNotificationDispatcher } from './notification-dispatcher.mjs'; import { createNotificationDispatcher } from './notification-dispatcher.mjs';
@@ -852,6 +852,8 @@ async function bootstrapUserAuth() {
ownerId: userId, ownerId: userId,
ownerSegment: resolveAnalyticsOwnerSegment(await userAuth?.getUserById(userId).catch(() => null) ?? {}), ownerSegment: resolveAnalyticsOwnerSegment(await userAuth?.getUserById(userId).catch(() => null) ?? {}),
ownerLabel: resolveAnalyticsOwnerLabel(await userAuth?.getUserById(userId).catch(() => null) ?? {}), ownerLabel: resolveAnalyticsOwnerLabel(await userAuth?.getUserById(userId).catch(() => null) ?? {}),
planType: resolveAnalyticsPlan(await userAuth?.getUserById(userId).catch(() => null) ?? {}),
generatedAt: new Date().toISOString(),
pageId: artifact.relativePath, pageId: artifact.relativePath,
publicationId: sessionId, publicationId: sessionId,
agentRunId: sessionId, agentRunId: sessionId,
@@ -5256,6 +5258,8 @@ api.get('/sessions/:sessionId/events', async (req, res, next) => {
ownerId: req.currentUser.id, ownerId: req.currentUser.id,
ownerSegment: resolveAnalyticsOwnerSegment(req.currentUser), ownerSegment: resolveAnalyticsOwnerSegment(req.currentUser),
ownerLabel: resolveAnalyticsOwnerLabel(req.currentUser), ownerLabel: resolveAnalyticsOwnerLabel(req.currentUser),
planType: resolveAnalyticsPlan(req.currentUser),
generatedAt: new Date().toISOString(),
pageId: relativePath, pageId: relativePath,
publicationId: sid, publicationId: sid,
agentRunId: sid, agentRunId: sid,
@@ -6330,6 +6334,7 @@ async function sendPublishFile(req, res, filePath, { isOwner = true } = {}) {
pageDataContext = { pageDataContext = {
pageId: page.id, pageId: page.id,
accessMode: page.publicationAccessMode ?? null, accessMode: page.publicationAccessMode ?? null,
generatedAt: page.createdAt ? new Date(page.createdAt).toISOString() : '',
}; };
} }
} }
@@ -6338,6 +6343,8 @@ async function sendPublishFile(req, res, filePath, { isOwner = true } = {}) {
ownerId: parsedPublishPath?.userId ?? '', ownerId: parsedPublishPath?.userId ?? '',
ownerSegment: resolveAnalyticsOwnerSegment(pageOwner ?? {}), ownerSegment: resolveAnalyticsOwnerSegment(pageOwner ?? {}),
ownerLabel: resolveAnalyticsOwnerLabel(pageOwner ?? {}), ownerLabel: resolveAnalyticsOwnerLabel(pageOwner ?? {}),
planType: resolveAnalyticsPlan(pageOwner ?? {}),
generatedAt: pageDataContext?.generatedAt ?? '',
pageId: pageDataContext?.pageId ?? '', pageId: pageDataContext?.pageId ?? '',
publicationId: pageDataContext?.publicationId ?? pageDataContext?.publication_id ?? '', publicationId: pageDataContext?.publicationId ?? pageDataContext?.publication_id ?? '',
config: mindSpaceAnalyticsConfig, config: mindSpaceAnalyticsConfig,
+6
View File
@@ -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) {