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_WEBSITE_ID=<local Umami Website ID>
# 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 产品壳层使用独立 Website,避免与用户生成页面的浏览口径混在一起
# 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
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`:
```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_WEBSITE_ID=<website-id>
MEMIND_ANALYTICS_ID_SECRET=<random-local-secret>
MEMIND_ANALYTICS_IDENTITY_MODE=raw
MEMIND_ANALYTICS_DOMAINS=127.0.0.1,localhost
MEMIND_PRODUCT_ANALYTICS_ENABLED=true
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
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.
visitor with the stable Memind user ID when `IDENTITY_MODE=raw` before
sending a standard Umami page view, so Users and Pageviews are populated.
The Identify properties include username, plan, channel, identity mode and
current public page URL. Generated-page events also retain `page_id` and the
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
`/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
product events use the same configured identity as generated pages, while
remaining in the separate product Website.
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),
websiteId,
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',
scriptPath: String(env.MEMIND_ANALYTICS_SCRIPT_PATH ?? '/analytics/script.js').trim() || '/analytics/script.js',
hostPath: String(env.MEMIND_ANALYTICS_HOST_PATH ?? '/analytics').trim() || '/analytics',
@@ -27,6 +30,7 @@ export function resolveProductAnalyticsConfig(
enabled: enabled && Boolean(websiteId) && Boolean(baseConfig?.idSecret),
websiteId,
idSecret: String(baseConfig?.idSecret ?? '').trim(),
identityMode: baseConfig?.identityMode === 'raw' ? 'raw' : 'pseudonymous',
scriptPath: String(baseConfig?.scriptPath ?? '/analytics/script.js').trim() || '/analytics/script.js',
hostPath: String(baseConfig?.hostPath ?? '/analytics').trim() || '/analytics',
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);
}
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 = {}) {
if (user?.role === 'admin') return 'admin';
const plan = String(user?.planType ?? user?.plan_type ?? 'free').trim().toLowerCase();
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 = {}) {
const label = String(user?.displayName ?? user?.display_name ?? user?.username ?? '').trim();
return label.replace(/[\r\n\t]+/g, ' ').slice(0, 80) || '未命名用户';
@@ -53,7 +70,7 @@ export function resolveAnalyticsOwnerLabel(user = {}) {
export function buildProductAnalyticsContext({ config, user = null } = {}) {
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 {
enabled: true,
websiteId: config.websiteId,
@@ -65,8 +82,10 @@ export function buildProductAnalyticsContext({ config, user = null } = {}) {
distinctId,
username: resolveAnalyticsOwnerLabel(user),
ownerSegment: resolveAnalyticsOwnerSegment(user),
planType: resolveAnalyticsPlan(user),
channel: 'h5',
surface: 'product',
identityMode: config.identityMode === 'raw' ? 'raw' : 'pseudonymous',
}
: null,
};
@@ -82,14 +101,17 @@ export function sendMindSpaceAnalyticsEvent({
channel = 'h5',
ownerSegment = 'unknown',
ownerLabel = '未命名用户',
planType = 'unknown',
generatedAt = '',
url = '',
} = {}) {
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);
const endpoint = `${String(config.analyticsUrl || 'http://127.0.0.1:3100').replace(/\/$/, '')}/api/send`;
const payload = {
website: config.websiteId,
id: owner,
hostname: '127.0.0.1',
url: url || '/',
name: String(eventName),
@@ -100,7 +122,10 @@ export function sendMindSpaceAnalyticsEvent({
agent_run_id: String(agentRunId || ''),
channel,
owner_segment: String(ownerSegment || 'unknown'),
plan_type: String(planType || 'unknown'),
owner_label: resolveAnalyticsOwnerLabel({ displayName: ownerLabel }),
generated_at: String(generatedAt || ''),
identity_mode: config.identityMode === 'raw' ? 'raw' : 'pseudonymous',
},
};
return fetch(endpoint, {
@@ -124,18 +149,17 @@ export function injectMindSpaceAnalytics(html, {
publicationId = '',
ownerSegment = 'unknown',
ownerLabel = '未命名用户',
planType = 'unknown',
generatedAt = '',
channel = 'h5',
config = resolveMindSpaceAnalyticsConfig(),
} = {}) {
const source = String(html ?? '');
if (!config?.enabled || !config.websiteId || !/^\s*(<!doctype html|<html\b)/i.test(source)) return source;
if (source.includes(ANALYTICS_MARKER)) return source;
const owner = pseudonymizeAnalyticsId(ownerId, config.idSecret);
const owner = resolveAnalyticsIdentity(ownerId, config);
if (!owner) return source;
// 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, surface: 'generated_page' };
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' };
const attrs = [
ANALYTICS_MARKER,
`data-website-id="${config.websiteId.replaceAll('"', '&quot;')}"`,
@@ -143,7 +167,7 @@ export function injectMindSpaceAnalytics(html, {
`data-host-url="${config.hostPath}"`,
];
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>`);
return source.replace(/<body\b/i, `${block}<body`);
}
+67 -2
View File
@@ -6,6 +6,8 @@ import {
buildProductAnalyticsContext,
injectMindSpaceAnalytics,
pseudonymizeAnalyticsId,
resolveAnalyticsIdentity,
resolveAnalyticsPlan,
resolveAnalyticsOwnerSegment,
resolveAnalyticsOwnerLabel,
resolveMindSpaceAnalyticsConfig,
@@ -34,8 +36,10 @@ test('product analytics uses a separate website and the existing pseudonymizatio
distinctId: pseudonymizeAnalyticsId('user-123', 'local-secret'),
username: '张三',
ownerSegment: 'plan:pro',
planType: 'pro',
channel: 'h5',
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'));
});
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', () => {
assert.equal(resolveAnalyticsOwnerSegment({ role: 'admin' }), 'admin');
assert.equal(resolveAnalyticsOwnerSegment({ role: 'user', planType: 'pro' }), 'plan:pro');
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', () => {
@@ -97,6 +109,8 @@ test('injects one local same-origin tracker with page dimensions', () => {
ownerLabel: '张三',
pageId: 'page-1',
publicationId: 'pub-1',
planType: 'pro',
generatedAt: '2026-07-20T01:02:03.000Z',
config: {
enabled: true,
websiteId: 'local-website',
@@ -104,12 +118,13 @@ test('injects one local same-origin tracker with page dimensions', () => {
scriptPath: '/analytics/script.js',
hostPath: '/analytics',
domains: '127.0.0.1,localhost',
identityMode: 'raw',
},
});
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,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.ok(out.indexOf('identify();pageview();') > 0);
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_scroll_/);
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);
});
@@ -172,6 +188,8 @@ test('identifies the pseudonymous owner before sending a standard page view', ()
owner_segment: 'plan:pro',
channel: 'h5',
surface: 'generated_page',
plan_type: 'unknown',
identity_mode: 'pseudonymous',
}],
['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 () => {
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 { PUBLISH_ROOT_DIR, PUBLISH_KEY_UUID, PUBLIC_ZONE_DIR } from './user-publish.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 { attachRequestId, sendData, sendError } from './api-response.mjs';
import { createNotificationDispatcher } from './notification-dispatcher.mjs';
@@ -852,6 +852,8 @@ async function bootstrapUserAuth() {
ownerId: userId,
ownerSegment: resolveAnalyticsOwnerSegment(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,
publicationId: sessionId,
agentRunId: sessionId,
@@ -5256,6 +5258,8 @@ api.get('/sessions/:sessionId/events', async (req, res, next) => {
ownerId: req.currentUser.id,
ownerSegment: resolveAnalyticsOwnerSegment(req.currentUser),
ownerLabel: resolveAnalyticsOwnerLabel(req.currentUser),
planType: resolveAnalyticsPlan(req.currentUser),
generatedAt: new Date().toISOString(),
pageId: relativePath,
publicationId: sid,
agentRunId: sid,
@@ -6330,6 +6334,7 @@ async function sendPublishFile(req, res, filePath, { isOwner = true } = {}) {
pageDataContext = {
pageId: page.id,
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 ?? '',
ownerSegment: resolveAnalyticsOwnerSegment(pageOwner ?? {}),
ownerLabel: resolveAnalyticsOwnerLabel(pageOwner ?? {}),
planType: resolveAnalyticsPlan(pageOwner ?? {}),
generatedAt: pageDataContext?.generatedAt ?? '',
pageId: pageDataContext?.pageId ?? '',
publicationId: pageDataContext?.publicationId ?? pageDataContext?.publication_id ?? '',
config: mindSpaceAnalyticsConfig,
+6
View File
@@ -5,8 +5,10 @@ type AnalyticsIdentity = {
distinctId: string;
username: string;
ownerSegment: string;
planType: string;
channel: string;
surface: 'product';
identityMode: 'raw' | 'pseudonymous';
};
type ProductAnalyticsContext = {
@@ -160,6 +162,8 @@ function trackProductEvent(
route_name: resolveProductRouteName(window.location.pathname),
channel: identity?.channel || 'h5',
owner_segment: identity?.ownerSegment || 'anonymous',
plan_type: identity?.planType || 'anonymous',
identity_mode: identity?.identityMode || 'anonymous',
...data,
},
}));
@@ -203,8 +207,10 @@ export function useProductAnalytics(userId?: string | null) {
void window.umami?.identify(identity.distinctId, {
username: identity.username,
owner_segment: identity.ownerSegment,
plan_type: identity.planType,
channel: identity.channel,
surface: identity.surface,
identity_mode: identity.identityMode,
});
}
} else if (lastIdentityKey) {