diff --git a/.env.example b/.env.example index b4d915b..c8c2f5a 100644 --- a/.env.example +++ b/.env.example @@ -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= # MEMIND_ANALYTICS_ID_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 diff --git a/docs/local-analytics.md b/docs/local-analytics.md index 3acbbb2..af850d2 100644 --- a/docs/local-analytics.md +++ b/docs/local-analytics.md @@ -10,9 +10,14 @@ MEMIND_ANALYTICS_ENABLED=true MEMIND_ANALYTICS_URL=http://127.0.0.1:3100 MEMIND_ANALYTICS_WEBSITE_ID= MEMIND_ANALYTICS_ID_SECRET= +MEMIND_ANALYTICS_IDENTITY_MODE=raw 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 prevents chat, MindSpace, and feedback navigation from inflating generated-page 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 events. Query strings are restricted to a small allowlist and click labels never copy arbitrary chat or generated-page text. Authenticated product events use the -same pseudonymous identity as generated pages while remaining in the separate +same configured identity as generated pages while remaining in the separate product Website. ## Rybbit (recommended for behavior analytics) diff --git a/mindspace-analytics.mjs b/mindspace-analytics.mjs index 64ca40d..9b85f8f 100644 --- a/mindspace-analytics.mjs +++ b/mindspace-analytics.mjs @@ -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*(`; + const block = ``; if (/<\/head>/i.test(source)) return source.replace(/<\/head>/i, `${block}`); return source.replace(/ { 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); +} diff --git a/mindspace-public-page-context.mjs b/mindspace-public-page-context.mjs index ba31976..b08908b 100644 --- a/mindspace-public-page-context.mjs +++ b/mindspace-public-page-context.mjs @@ -150,6 +150,9 @@ export async function resolveMindSpacePageDataContext({ pageId: page.id, accessMode: page.publicationAccessMode ?? null, publicationId: page.currentPublishId ?? page.current_publish_id ?? null, + ...(page.createdAt + ? { generatedAt: new Date(page.createdAt).toISOString() } + : {}), }; } diff --git a/mindspace-public-page-context.test.mjs b/mindspace-public-page-context.test.mjs index 35bfdbf..539d8a4 100644 --- a/mindspace-public-page-context.test.mjs +++ b/mindspace-public-page-context.test.mjs @@ -98,6 +98,7 @@ test('resolveMindSpacePageDataContext falls back to the MindSpace page service', id: 'remote-page', publicationAccessMode: 'internal', 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', accessMode: 'internal', publicationId: 'remote-publish', + generatedAt: '2026-07-20T12:34:56.000Z', }); }); diff --git a/server/portal-integration-services-bootstrap.mjs b/server/portal-integration-services-bootstrap.mjs index 4b06248..fc76e4c 100644 --- a/server/portal-integration-services-bootstrap.mjs +++ b/server/portal-integration-services-bootstrap.mjs @@ -1,6 +1,7 @@ import { resolveAnalyticsOwnerLabel, resolveAnalyticsOwnerSegment, + resolveAnalyticsPlan, sendMindSpaceAnalyticsEvent, } from '../mindspace-analytics.mjs'; import { @@ -43,6 +44,7 @@ export async function bootstrapPortalIntegrationServices({ resolveAnalyticsOwnerSegment, resolveAnalyticsOwnerLabelFn = resolveAnalyticsOwnerLabel, + resolveAnalyticsPlanFn = resolveAnalyticsPlan, sendMindSpaceAnalyticsEventFn = sendMindSpaceAnalyticsEvent, sendMindSpaceRybbitEventFn = @@ -155,6 +157,8 @@ export async function bootstrapPortalIntegrationServices({ ownerLabel: resolveAnalyticsOwnerLabelFn( pageOwner, ), + planType: resolveAnalyticsPlanFn(pageOwner), + generatedAt: new Date().toISOString(), pageId: artifact.relativePath, publicationId: sessionId, agentRunId: sessionId, diff --git a/server/portal-integration-services-bootstrap.test.mjs b/server/portal-integration-services-bootstrap.test.mjs index bbe6ffc..381ca83 100644 --- a/server/portal-integration-services-bootstrap.test.mjs +++ b/server/portal-integration-services-bootstrap.test.mjs @@ -148,6 +148,10 @@ function createSetup(overrides = {}) { calls.push(['owner-label', user]); return 'label'; }, + resolveAnalyticsPlanFn(user) { + calls.push(['plan-type', user]); + return 'pro'; + }, sendMindSpaceAnalyticsEventFn(event) { calls.push(['analytics', event]); return Promise.resolve(); @@ -349,12 +353,18 @@ test('preserves generated-page analytics projection', async () => { ownerId: 'user-1', ownerSegment: 'segment', ownerLabel: 'label', + planType: 'pro', + generatedAt: analyticsCall[1].generatedAt, pageId: 'public/page.html', publicationId: 'session-1', agentRunId: 'session-1', channel: 'wechat_mp', 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( ([name]) => name === 'rybbit', ); diff --git a/server/portal-session-routes.mjs b/server/portal-session-routes.mjs index 4ab18f8..0a3065f 100644 --- a/server/portal-session-routes.mjs +++ b/server/portal-session-routes.mjs @@ -9,6 +9,7 @@ import { import { resolveAnalyticsOwnerLabel, resolveAnalyticsOwnerSegment, + resolveAnalyticsPlan, sendMindSpaceAnalyticsEvent, } from '../mindspace-analytics.mjs'; import { @@ -96,6 +97,7 @@ export function attachPortalSessionRoutes( resolveAnalyticsOwnerSegment, resolveAnalyticsOwnerLabelFn = resolveAnalyticsOwnerLabel, + resolveAnalyticsPlanFn = resolveAnalyticsPlan, syncPublicHtmlAfterFinishFn = syncPublicHtmlAfterFinish, resolveMindSpaceRuntimeConfigFn = resolveMindSpaceRuntimeConfig, @@ -432,6 +434,8 @@ export function attachPortalSessionRoutes( resolveAnalyticsOwnerSegmentFn(req.currentUser), ownerLabel: resolveAnalyticsOwnerLabelFn(req.currentUser), + planType: resolveAnalyticsPlanFn(req.currentUser), + generatedAt: new Date().toISOString(), pageId: relativePath, publicationId: sessionId, agentRunId: sessionId, diff --git a/server/portal-session-routes.test.mjs b/server/portal-session-routes.test.mjs index 5000faf..42e8063 100644 --- a/server/portal-session-routes.test.mjs +++ b/server/portal-session-routes.test.mjs @@ -165,6 +165,7 @@ function createDependencies(overrides = {}) { sendMindSpaceAnalyticsEventFn: async () => {}, resolveAnalyticsOwnerSegmentFn: () => 'segment', resolveAnalyticsOwnerLabelFn: () => 'label', + resolveAnalyticsPlanFn: () => 'pro', syncPublicHtmlAfterFinishFn: async () => ({ 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[2].input.publicationId, '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 () => { diff --git a/server/portal-workspace-publication-delivery.mjs b/server/portal-workspace-publication-delivery.mjs index 8ad8c4d..b02aa52 100644 --- a/server/portal-workspace-publication-delivery.mjs +++ b/server/portal-workspace-publication-delivery.mjs @@ -7,6 +7,7 @@ import { injectMindSpaceAnalytics, resolveAnalyticsOwnerLabel, resolveAnalyticsOwnerSegment, + resolveAnalyticsPlan, } from '../mindspace-analytics.mjs'; import { injectMindSpaceRybbit, @@ -303,6 +304,8 @@ export function createPortalWorkspacePublicationDelivery({ ownerLabel: resolveAnalyticsOwnerLabel( pageOwner ?? {}, ), + planType: resolveAnalyticsPlan(pageOwner ?? {}), + generatedAt: pageDataContext?.generatedAt ?? '', pageId: pageDataContext?.pageId ?? '', publicationId: diff --git a/src/analytics/productAnalytics.ts b/src/analytics/productAnalytics.ts index 8269fe0..13dd814 100644 --- a/src/analytics/productAnalytics.ts +++ b/src/analytics/productAnalytics.ts @@ -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) {