Merge pull request 'fix(analytics): identify Memind users before pageviews' (#13) from codex/fix-umami-users-identify into main
Memind CI / Test, build, and release guards (push) Successful in 4m25s

This commit was merged in pull request #13.
This commit is contained in:
2026-07-17 06:17:00 +00:00
3 changed files with 65 additions and 7 deletions
+5 -1
View File
@@ -24,7 +24,11 @@ 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
same-origin `/analytics/script.js` tracker and a `page_view` event with
same-origin `/analytics/script.js` tracker. The tracker identifies the
visitor with a stable pseudonymous owner ID before sending a standard Umami
page view, so Users and Pageviews are populated. The Identify properties
include the readable Memind username and current public page URL for
operational analytics. Click, form, scroll, and engagement events retain the
pseudonymous `owner_id`, `page_id`, and `channel` dimensions.
The integration is fail-open: missing configuration, disabled analytics, or a
+5 -5
View File
@@ -95,10 +95,10 @@ export function injectMindSpaceAnalytics(html, {
if (source.includes(ANALYTICS_MARKER)) return source;
const owner = pseudonymizeAnalyticsId(ownerId, config.idSecret);
if (!owner) return source;
// Public page source must not contain a readable account name. The stable
// pseudonym and coarse plan segment are sufficient for page analytics;
// readable labels are reserved for server-originated events only.
const metadata = { owner_id: owner, owner_segment: String(ownerSegment || 'unknown'), page_id: String(pageId || ''), publication_id: String(publicationId || ''), channel };
// The stable pseudonym remains the Umami identity key. The readable username
// is an explicitly enabled analytics property so operators can recognize the
// Memind user, while the current public page URL is resolved in the browser.
const metadata = { owner_id: owner, username: resolveAnalyticsOwnerLabel({ displayName: ownerLabel }), owner_segment: String(ownerSegment || 'unknown'), page_id: String(pageId || ''), publication_id: String(publicationId || ''), channel };
const attrs = [
ANALYTICS_MARKER,
`data-website-id="${config.websiteId.replaceAll('"', '"')}"`,
@@ -106,7 +106,7 @@ export function injectMindSpaceAnalytics(html, {
`data-host-url="${config.hostPath}"`,
];
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 once(n,x){if(seen[n])return;seen[n]=1;t(n,x);}function ready(){t('page_view');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 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>`;
if (/<\/head>/i.test(source)) return source.replace(/<\/head>/i, `${block}</head>`);
return source.replace(/<body\b/i, `${block}<body`);
}
+55 -1
View File
@@ -1,5 +1,6 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import vm from 'node:vm';
import {
injectMindSpaceAnalytics,
@@ -64,10 +65,14 @@ test('injects one local same-origin tracker with page dimensions', () => {
assert.match(out, /src="\/analytics\/script\.js"/);
assert.match(out, /data-host-url="\/analytics"/);
assert.match(out, /data-auto-track="false"/);
assert.match(out, /window\.umami\.identify\(d\.owner_id,\{username:d\.username,memind_page_url:location\.href,owner_segment:d\.owner_segment,channel:d\.channel\}\)/);
assert.match(out, /function pageview\(\).*window\.umami\.track\(\)/);
assert.ok(out.indexOf('identify();pageview();') > 0);
assert.doesNotMatch(out, /t\('page_view'\)/);
assert.match(out, /page_id/);
assert.match(out, /owner_segment/);
assert.doesNotMatch(out, /owner_label/);
assert.doesNotMatch(out, /张三/);
assert.match(out, /"username":"张三"/);
assert.match(out, /page_click/);
assert.match(out, /page_form_submit/);
assert.match(out, /page_scroll_/);
@@ -76,6 +81,55 @@ test('injects one local same-origin tracker with page dimensions', () => {
assert.equal(injectMindSpaceAnalytics(out, { ownerId: 'user-123', config: { enabled: true, websiteId: 'local-website', idSecret: 'secret' } }), out);
});
test('identifies the pseudonymous owner before sending a standard page view', () => {
const out = injectMindSpaceAnalytics('<!doctype html><html><head></head><body></body></html>', {
ownerId: 'user-123',
ownerSegment: 'plan:pro',
ownerLabel: '张三',
channel: 'h5',
config: {
enabled: true,
websiteId: 'local-website',
idSecret: 'secret',
scriptPath: '/analytics/script.js',
hostPath: '/analytics',
},
});
const inlineScript = out.match(/<script data-memind-analytics="1">([\s\S]*?)<\/script>/)?.[1];
assert.ok(inlineScript);
const calls = [];
vm.runInNewContext(inlineScript, {
window: {
umami: {
identify: (...args) => calls.push(['identify', ...args]),
track: (...args) => calls.push(['track', ...args]),
},
innerHeight: 800,
scrollY: 0,
addEventListener: () => {},
},
document: {
readyState: 'complete',
title: 'Demo',
documentElement: { scrollHeight: 1600 },
addEventListener: () => {},
},
location: { href: 'https://m.tkmind.cn/MindSpace/demo/public/page.html' },
setTimeout: () => {},
});
assert.deepEqual(JSON.parse(JSON.stringify(calls)), [
['identify', pseudonymizeAnalyticsId('user-123', 'secret'), {
username: '张三',
memind_page_url: 'https://m.tkmind.cn/MindSpace/demo/public/page.html',
owner_segment: 'plan:pro',
channel: 'h5',
}],
['track'],
]);
});
test('does not alter non-full-html or disabled pages', () => {
const fragment = '<div>hello</div>';
assert.equal(injectMindSpaceAnalytics(fragment, { ownerId: 'u', config: { enabled: true, websiteId: 'w', idSecret: 's' } }), fragment);