From 320945a4853825313935ca67fd2b12da88a41abe Mon Sep 17 00:00:00 2001 From: john Date: Thu, 16 Jul 2026 21:06:28 +0800 Subject: [PATCH] feat(mindspace): add opt-in local analytics --- .env.example | 8 +++ docs/local-analytics.md | 33 +++++++++++ mindspace-analytics.mjs | 109 +++++++++++++++++++++++++++++++++++ mindspace-analytics.test.mjs | 86 +++++++++++++++++++++++++++ mindspace-config.mjs | 60 ++++++++++++++++++- mindspace-config.test.mjs | 32 ++++++++++ package.json | 2 +- server.mjs | 75 ++++++++++++++++++++++-- wechat-mp.mjs | 13 +++++ 9 files changed, 412 insertions(+), 6 deletions(-) create mode 100644 docs/local-analytics.md create mode 100644 mindspace-analytics.mjs create mode 100644 mindspace-analytics.test.mjs diff --git a/.env.example b/.env.example index e4dea31..6dd413b 100644 --- a/.env.example +++ b/.env.example @@ -12,6 +12,14 @@ H5_PORT=8081 # pnpm dev / dev-core 在未显式覆盖时会把 H5_PUBLIC_BASE_URL 设为 8081。 H5_PUBLIC_BASE_URL=http://127.0.0.1:5173 +# Local Umami analytics (disabled by default; points only to the local +# memind-analytics service when enabled). +# MEMIND_ANALYTICS_ENABLED=true +# MEMIND_ANALYTICS_URL=http://127.0.0.1:3100 +# MEMIND_ANALYTICS_WEBSITE_ID= +# MEMIND_ANALYTICS_ID_SECRET= +# MEMIND_ANALYTICS_DOMAINS=127.0.0.1,localhost + # 生产 H5 public base 当前临时切到 https://mm.tkmind.cn。 # 后续公网 H5 不再依赖 105 转发链路;m.tkmind.cn 仅作为 legacy/rollback 记录处理。 # H5_PUBLIC_BASE_URL=https://mm.tkmind.cn diff --git a/docs/local-analytics.md b/docs/local-analytics.md new file mode 100644 index 0000000..c7891b7 --- /dev/null +++ b/docs/local-analytics.md @@ -0,0 +1,33 @@ +# Local Memind + Umami analytics + +The integration is local-only by default. Memind proxies `/analytics/*` to the +local Umami service at `http://127.0.0.1:3100`; it does not contact 103/105. + +1. Start `/Users/john/Project/memind-analytics` and verify: + + ```bash + curl --fail http://127.0.0.1:3100/api/heartbeat + ``` + +2. Create one Umami Website for the local generated-page host. Do not create a + Website per page or per user. + +3. Put the Website ID and a local-only pseudonymization secret in Memind's + `.env`: + + ```dotenv + MEMIND_ANALYTICS_ENABLED=true + MEMIND_ANALYTICS_URL=http://127.0.0.1:3100 + MEMIND_ANALYTICS_WEBSITE_ID= + MEMIND_ANALYTICS_ID_SECRET= + MEMIND_ANALYTICS_DOMAINS=127.0.0.1,localhost + ``` + +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 + pseudonymous `owner_id`, `page_id`, and `channel` dimensions. + +The integration is fail-open: missing configuration, disabled analytics, or a +down Umami service leaves page generation and page delivery unchanged. User +facing analytics must be queried through a future Memind API that filters by +the authenticated owner; do not expose the Umami dashboard directly to users. diff --git a/mindspace-analytics.mjs b/mindspace-analytics.mjs new file mode 100644 index 0000000..a619d31 --- /dev/null +++ b/mindspace-analytics.mjs @@ -0,0 +1,109 @@ +import crypto from 'node:crypto'; + +const ANALYTICS_MARKER = 'data-memind-analytics="1"'; + +export function resolveMindSpaceAnalyticsConfig(env = process.env) { + const enabled = String(env.MEMIND_ANALYTICS_ENABLED ?? '').toLowerCase() === 'true'; + const websiteId = String(env.MEMIND_ANALYTICS_WEBSITE_ID ?? '').trim(); + const secret = String(env.MEMIND_ANALYTICS_ID_SECRET ?? '').trim(); + return { + enabled: enabled && Boolean(websiteId) && Boolean(secret), + websiteId, + idSecret: secret, + 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', + domains: String(env.MEMIND_ANALYTICS_DOMAINS ?? '').trim(), + }; +} + +export function pseudonymizeAnalyticsId(value, secret) { + const normalized = String(value ?? '').trim(); + const key = String(secret ?? '').trim(); + if (!normalized || !key) return ''; + return crypto.createHmac('sha256', key).update(normalized).digest('hex').slice(0, 32); +} + +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 resolveAnalyticsOwnerLabel(user = {}) { + const label = String(user?.displayName ?? user?.display_name ?? user?.username ?? '').trim(); + return label.replace(/[\r\n\t]+/g, ' ').slice(0, 80) || '未命名用户'; +} + +export function sendMindSpaceAnalyticsEvent({ + config, + eventName, + ownerId, + pageId = '', + publicationId = '', + agentRunId = '', + channel = 'h5', + ownerSegment = 'unknown', + ownerLabel = '未命名用户', + url = '', +} = {}) { + if (!config?.enabled || !config.websiteId || !config.idSecret || !eventName) return Promise.resolve(false); + const owner = pseudonymizeAnalyticsId(ownerId, config.idSecret); + 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, + hostname: '127.0.0.1', + url: url || '/', + name: String(eventName), + data: { + owner_id: owner, + page_id: String(pageId || ''), + publication_id: String(publicationId || ''), + agent_run_id: String(agentRunId || ''), + channel, + owner_segment: String(ownerSegment || 'unknown'), + owner_label: resolveAnalyticsOwnerLabel({ displayName: ownerLabel }), + }, + }; + return fetch(endpoint, { + method: 'POST', + headers: { 'content-type': 'application/json', 'user-agent': 'Memind/local-analytics' }, + body: JSON.stringify({ type: 'event', payload }), + signal: AbortSignal.timeout(1500), + }).then((response) => response.ok).catch(() => false); +} + +function jsonForInlineScript(value) { + return JSON.stringify(value) + .replaceAll('<', '\\u003c') + .replaceAll('>', '\\u003e') + .replaceAll('&', '\\u0026'); +} + +export function injectMindSpaceAnalytics(html, { + ownerId, + pageId = '', + publicationId = '', + ownerSegment = 'unknown', + ownerLabel = '未命名用户', + channel = 'h5', + config = resolveMindSpaceAnalyticsConfig(), +} = {}) { + const source = String(html ?? ''); + if (!config?.enabled || !config.websiteId || !/^\s*(`; + if (/<\/head>/i.test(source)) return source.replace(/<\/head>/i, `${block}`); + return source.replace(/ { + assert.equal(resolveMindSpaceAnalyticsConfig({ MEMIND_ANALYTICS_ENABLED: 'true' }).enabled, false); + assert.equal(resolveMindSpaceAnalyticsConfig({ + MEMIND_ANALYTICS_ENABLED: 'true', + MEMIND_ANALYTICS_WEBSITE_ID: 'local-website', + }).enabled, false); + const config = resolveMindSpaceAnalyticsConfig({ + MEMIND_ANALYTICS_ENABLED: 'true', + MEMIND_ANALYTICS_URL: 'http://127.0.0.1:3200', + MEMIND_ANALYTICS_WEBSITE_ID: 'local-website', + MEMIND_ANALYTICS_ID_SECRET: 'local-secret', + }); + assert.equal(config.enabled, true); + assert.equal(config.analyticsUrl, 'http://127.0.0.1:3200'); + assert.equal(config.hostPath, '/analytics'); +}); + +test('owner ids are stable pseudonyms and never expose the source id', () => { + const first = pseudonymizeAnalyticsId('user-123', 'secret'); + assert.equal(first, pseudonymizeAnalyticsId('user-123', 'secret')); + assert.notEqual(first, 'user-123'); + assert.notEqual(first, pseudonymizeAnalyticsId('user-456', 'secret')); +}); + +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'); +}); + +test('owner labels are readable but bounded and stripped of control characters', () => { + assert.equal(resolveAnalyticsOwnerLabel({ displayName: '张三\n管理员' }), '张三 管理员'); + assert.equal(resolveAnalyticsOwnerLabel({}), '未命名用户'); +}); + +test('injects one local same-origin tracker with page dimensions', () => { + const html = 'Demo

Demo

'; + const out = injectMindSpaceAnalytics(html, { + ownerId: 'user-123', + pageId: 'page-1', + publicationId: 'pub-1', + config: { + enabled: true, + websiteId: 'local-website', + idSecret: 'secret', + scriptPath: '/analytics/script.js', + hostPath: '/analytics', + domains: '127.0.0.1,localhost', + }, + }); + assert.match(out, /src="\/analytics\/script\.js"/); + assert.match(out, /data-host-url="\/analytics"/); + assert.match(out, /data-auto-track="false"/); + assert.match(out, /page_id/); + assert.match(out, /owner_segment/); + assert.match(out, /owner_label/); + assert.match(out, /page_click/); + assert.match(out, /page_form_submit/); + assert.match(out, /page_scroll_/); + assert.match(out, /page_engaged_10s/); + assert.doesNotMatch(out, /user-123/); + assert.equal(injectMindSpaceAnalytics(out, { ownerId: 'user-123', config: { enabled: true, websiteId: 'local-website', idSecret: 'secret' } }), out); +}); + +test('does not alter non-full-html or disabled pages', () => { + const fragment = '
hello
'; + assert.equal(injectMindSpaceAnalytics(fragment, { ownerId: 'u', config: { enabled: true, websiteId: 'w', idSecret: 's' } }), fragment); + const html = ''; + assert.equal(injectMindSpaceAnalytics(html, { ownerId: 'u', config: { enabled: false, websiteId: 'w', idSecret: 's' } }), html); +}); + +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); +}); diff --git a/mindspace-config.mjs b/mindspace-config.mjs index eb3181c..056c5c6 100644 --- a/mindspace-config.mjs +++ b/mindspace-config.mjs @@ -1,4 +1,33 @@ +import crypto from 'node:crypto'; + const PUBLIC_PAGE_LIMIT_KEY = 'public_page_limit'; +const ANALYTICS_ENABLED_KEY = 'analytics_enabled'; +const ANALYTICS_WEBSITE_ID_KEY = 'analytics_website_id'; +const ANALYTICS_URL_KEY = 'analytics_url'; +const ANALYTICS_DOMAINS_KEY = 'analytics_domains'; +const ANALYTICS_ID_SECRET_KEY = 'analytics_id_secret'; + +function secretKey(env = process.env) { + return crypto.createHash('sha256').update(String(env.TKMIND_SERVER__SECRET_KEY ?? 'local-dev-secret')).digest(); +} + +function encryptSecret(value, env = process.env) { + const iv = crypto.randomBytes(12); + const cipher = crypto.createCipheriv('aes-256-gcm', secretKey(env), iv); + const ciphertext = Buffer.concat([cipher.update(String(value), 'utf8'), cipher.final()]); + return JSON.stringify({ v: 1, iv: iv.toString('base64'), tag: cipher.getAuthTag().toString('base64'), data: ciphertext.toString('base64') }); +} + +function decryptSecret(value, env = process.env) { + try { + const payload = JSON.parse(String(value)); + const decipher = crypto.createDecipheriv('aes-256-gcm', secretKey(env), Buffer.from(payload.iv, 'base64')); + decipher.setAuthTag(Buffer.from(payload.tag, 'base64')); + return Buffer.concat([decipher.update(Buffer.from(payload.data, 'base64')), decipher.final()]).toString('utf8'); + } catch { + return ''; + } +} function asPositiveInteger(value, fallback) { const parsed = Number(value); @@ -20,6 +49,13 @@ async function ensureConfigTable(pool) { export function defaultMindSpaceConfig(env = process.env) { return { publicPageLimit: asPositiveInteger(env.MINDSPACE_FREE_PUBLIC_PAGE_LIMIT ?? 5, 5), + analytics: { + enabled: String(env.MEMIND_ANALYTICS_ENABLED ?? '').toLowerCase() === 'true', + websiteId: String(env.MEMIND_ANALYTICS_WEBSITE_ID ?? '').trim(), + analyticsUrl: String(env.MEMIND_ANALYTICS_URL ?? 'http://127.0.0.1:3100').trim(), + domains: String(env.MEMIND_ANALYTICS_DOMAINS ?? '127.0.0.1,localhost').trim(), + idSecretConfigured: Boolean(String(env.MEMIND_ANALYTICS_ID_SECRET ?? '').trim()), + }, }; } @@ -45,7 +81,7 @@ export async function ensureMindSpaceConfig(pool, { env = process.env, seedDefau ); } -export async function loadMindSpaceConfig(pool, { env = process.env } = {}) { +export async function loadMindSpaceConfig(pool, { env = process.env, includeAnalyticsSecret = false } = {}) { const config = defaultMindSpaceConfig(env); try { const rows = await readConfigRows(pool); @@ -53,6 +89,15 @@ export async function loadMindSpaceConfig(pool, { env = process.env } = {}) { if (row.key === PUBLIC_PAGE_LIMIT_KEY) { config.publicPageLimit = asPositiveInteger(row.value, config.publicPageLimit); } + if (row.key === ANALYTICS_ENABLED_KEY) config.analytics.enabled = row.value === 'true'; + if (row.key === ANALYTICS_WEBSITE_ID_KEY) config.analytics.websiteId = String(row.value ?? ''); + if (row.key === ANALYTICS_URL_KEY) config.analytics.analyticsUrl = String(row.value ?? ''); + if (row.key === ANALYTICS_DOMAINS_KEY) config.analytics.domains = String(row.value ?? ''); + if (row.key === ANALYTICS_ID_SECRET_KEY) { + const secret = decryptSecret(row.value, env); + config.analytics.idSecretConfigured = Boolean(secret); + if (includeAnalyticsSecret) config.analytics.idSecret = secret; + } } } catch (error) { if (error?.code === 'ER_NO_SUCH_TABLE') return config; @@ -73,6 +118,14 @@ export async function updateMindSpaceConfig(pool, patch, { env = process.env } = } updates.push([PUBLIC_PAGE_LIMIT_KEY, String(publicPageLimit), '公开页面数量上限']); } + if (patch?.analytics) { + const analytics = patch.analytics; + if (analytics.enabled !== undefined) updates.push([ANALYTICS_ENABLED_KEY, String(Boolean(analytics.enabled)), '本地 Umami 分析开关']); + if (analytics.websiteId !== undefined) updates.push([ANALYTICS_WEBSITE_ID_KEY, String(analytics.websiteId ?? '').trim(), '本地 Umami Website ID']); + if (analytics.analyticsUrl !== undefined) updates.push([ANALYTICS_URL_KEY, String(analytics.analyticsUrl || 'http://127.0.0.1:3100').trim(), '本地 Umami 地址']); + if (analytics.domains !== undefined) updates.push([ANALYTICS_DOMAINS_KEY, String(analytics.domains ?? '').trim(), '本地统计域名']); + if (analytics.idSecret !== undefined && String(analytics.idSecret).trim()) updates.push([ANALYTICS_ID_SECRET_KEY, encryptSecret(String(analytics.idSecret).trim(), env), '本地分析匿名化密钥']); + } if (updates.length === 0) return loadMindSpaceConfig(pool, { env }); @@ -93,4 +146,9 @@ export async function updateMindSpaceConfig(pool, patch, { env = process.env } = export const mindspaceConfigInternals = { PUBLIC_PAGE_LIMIT_KEY, + ANALYTICS_ENABLED_KEY, + ANALYTICS_WEBSITE_ID_KEY, + ANALYTICS_URL_KEY, + ANALYTICS_DOMAINS_KEY, + ANALYTICS_ID_SECRET_KEY, }; diff --git a/mindspace-config.test.mjs b/mindspace-config.test.mjs index 2fd1364..fba028d 100644 --- a/mindspace-config.test.mjs +++ b/mindspace-config.test.mjs @@ -64,3 +64,35 @@ test('updateMindSpaceConfig persists a positive integer limit', async () => { assert.equal(config.publicPageLimit, 15); assert.equal(calls.some(({ sql }) => sql.includes('ON DUPLICATE KEY UPDATE')), true); }); + +test('analytics settings encrypt the id secret and only reveal it on internal loads', async () => { + const rows = new Map(); + const pool = { + async query(sql, params = []) { + if (sql.includes('FROM mindspace_config')) { + return [[...rows].map(([key, value]) => ({ key, value }))]; + } + if (sql.includes('ON DUPLICATE KEY UPDATE')) rows.set(params[0], params[1]); + return [[]]; + }, + }; + const env = { TKMIND_SERVER__SECRET_KEY: 'test-server-secret' }; + + const publicConfig = await updateMindSpaceConfig(pool, { + analytics: { + enabled: true, + websiteId: 'website-1', + analyticsUrl: 'http://127.0.0.1:3200', + domains: 'localhost', + idSecret: 'analytics-secret', + }, + }, { env }); + + assert.equal(publicConfig.analytics.enabled, true); + assert.equal(publicConfig.analytics.idSecretConfigured, true); + assert.equal('idSecret' in publicConfig.analytics, false); + assert.doesNotMatch(rows.get('analytics_id_secret'), /analytics-secret/); + + const internalConfig = await loadMindSpaceConfig(pool, { env, includeAnalyticsSecret: true }); + assert.equal(internalConfig.analytics.idSecret, 'analytics-secret'); +}); diff --git a/package.json b/package.json index c5d970e..b2aac25 100644 --- a/package.json +++ b/package.json @@ -58,7 +58,7 @@ "test:scenario": "node scripts/run-scenario-test.mjs", "verify:children-hobby-diet-survey": "node scripts/verify-children-hobby-diet-survey.mjs", "test:scenario:john4-diet": "node scripts/run-scenario-test.mjs --scenario john4-children-hobby-diet-update", - "test": "node --test auth.test.mjs asr-proxy.test.mjs billing.test.mjs billing-token-state.test.mjs billing-recharge.test.mjs wechat-pay.test.mjs wechat-oauth.test.mjs wechat-mp.test.mjs schedule-intent.test.mjs schedule-reminder-worker.test.mjs capabilities.test.mjs policies.test.mjs chat-skills.test.mjs chat-intent-router.test.mjs chat-finish-sync.test.mjs chat-agent-run-gate.test.mjs conversation-display.test.mjs user-publish.test.mjs user-memory-profile.test.mjs skills-registry.test.mjs agent-run-gateway.test.mjs agent-run-routes.test.mjs session-broker.test.mjs sse-event-taxonomy.test.mjs goosed-proxy-boundary.test.mjs agent-run-stream.test.mjs mindspace-h5-html-finish-guard.test.mjs admin-routes.test.mjs direct-chat-service.test.mjs tool-gateway.test.mjs mindspace.test.mjs mindspace-scan.test.mjs mindspace-assets.test.mjs mindspace-local-runtime-services.test.mjs mindspace-local-server-adapter.test.mjs mindspace-public-asset-token.test.mjs mindspace-remote-server-adapter.test.mjs mindspace-server-adapter.test.mjs mindspace-pages.test.mjs mindspace-page-sync-service.test.mjs public-site-bases.test.mjs mindspace-html-download-links.test.mjs mindspace-page-purge.test.mjs mindspace-public-delivery.test.mjs mindspace-public-page-context.test.mjs mindspace-published-page-csp.test.mjs mindspace-published-script-localize.test.mjs agent-run-deliverable-check.test.mjs mindspace-public-route.test.mjs mindspace-publications.test.mjs mindspace-public-links.test.mjs mindspace-chat-save.test.mjs mindspace-chat-docx-package.test.mjs mindspace-public-finish-sync.test.mjs mindspace-chat-context.test.mjs mindspace-canonical-url.test.mjs mindspace-conversation-package.test.mjs mindspace-conversation-package-backfill.test.mjs mindspace-conversation-package-public-html.test.mjs mindspace-conversation-package-verify.test.mjs mindspace-conversation-package-registry.test.mjs mindspace-conversation-package-routes.test.mjs mindspace-conversation-package-store.test.mjs mindspace-conversation-schema.test.mjs mindspace-runtime-config.test.mjs mindspace-service.test.mjs mindspace-storage-adapter.test.mjs mindspace-content-scan.test.mjs mindspace-html-localize.test.mjs mindspace-visual-editor.test.mjs mindspace-cleanup.test.mjs mindspace-thumbnails.test.mjs mindspace-workspace-thumbnails.test.mjs mindspace-workspace-sync.test.mjs mindspace-asset-preview.test.mjs mindspace-agent-jobs.test.mjs mindspace-agent-runner.test.mjs mindspace-sandbox-mcp.test.mjs user-data-space-service.test.mjs page-data-routes.test.mjs page-access-policy.test.mjs page-access-visitor.test.mjs page-data-public-service.test.mjs page-data-integration.test.mjs page-data-log-store.test.mjs page-data-ops.test.mjs page-data-session-store.test.mjs page-data-browser-client.test.mjs page-data-policy-index.test.mjs message-stream.test.mjs mindspace-service/mindspace-rpc-server.test.mjs plaza-posts.test.mjs plaza-interactions.test.mjs plaza-algorithm.test.mjs plaza-seo.test.mjs plaza-ops.test.mjs user-auth.test.mjs llm-providers.test.mjs admin-guard.test.mjs user-feedback.test.mjs memory-v2.test.mjs memory-v2-admin-config.test.mjs memory-v2-adapter-scaffold.test.mjs memory-v2-backend-contract.test.mjs memory-v2-health.test.mjs memory-v2-runtime.test.mjs memory-v2-plugin-backends.test.mjs memory-v2-pgvector.test.mjs memory-v2-pgvector-schema.test.mjs memory-v2-pgvector-backfill.test.mjs memory-v2-pgvector-smoke.test.mjs memory-v2-qdrant.test.mjs memory-v2-weaviate.test.mjs memory-v2-mem0.test.mjs memory-v2-letta.test.mjs memory-v2-external-adapters.test.mjs scripts/embed-memory-v2-local-hash.test.mjs scripts/check-memory-v2-app-canary.test.mjs scripts/check-memory-v2-config-gaps.test.mjs scripts/check-memory-v2-contracts.test.mjs scripts/check-memory-v2-health.test.mjs scripts/check-memory-v2-session-flow.test.mjs scripts/check-memory-v2-stack.test.mjs scripts/setup-memory-v2-pgvector-schema.test.mjs scripts/backfill-memory-v2-pgvector.test.mjs scripts/scaffold-memory-v2-backend.test.mjs scripts/smoke-memory-v2-pgvector.test.mjs scripts/smoke-memory-v2-qdrant.test.mjs scripts/smoke-memory-v2-external.test.mjs scripts/mock-memory-v2-services.test.mjs", + "test": "node --test auth.test.mjs asr-proxy.test.mjs billing.test.mjs billing-token-state.test.mjs billing-recharge.test.mjs wechat-pay.test.mjs wechat-oauth.test.mjs wechat-mp.test.mjs schedule-intent.test.mjs schedule-reminder-worker.test.mjs capabilities.test.mjs policies.test.mjs chat-skills.test.mjs chat-intent-router.test.mjs chat-finish-sync.test.mjs chat-agent-run-gate.test.mjs conversation-display.test.mjs user-publish.test.mjs user-memory-profile.test.mjs skills-registry.test.mjs agent-run-gateway.test.mjs agent-run-routes.test.mjs session-broker.test.mjs sse-event-taxonomy.test.mjs goosed-proxy-boundary.test.mjs agent-run-stream.test.mjs mindspace-h5-html-finish-guard.test.mjs admin-routes.test.mjs direct-chat-service.test.mjs tool-gateway.test.mjs mindspace.test.mjs mindspace-scan.test.mjs mindspace-assets.test.mjs mindspace-local-runtime-services.test.mjs mindspace-local-server-adapter.test.mjs mindspace-public-asset-token.test.mjs mindspace-remote-server-adapter.test.mjs mindspace-server-adapter.test.mjs mindspace-pages.test.mjs mindspace-page-sync-service.test.mjs public-site-bases.test.mjs mindspace-html-download-links.test.mjs mindspace-page-purge.test.mjs mindspace-public-delivery.test.mjs mindspace-public-page-context.test.mjs mindspace-published-page-csp.test.mjs mindspace-published-script-localize.test.mjs agent-run-deliverable-check.test.mjs mindspace-public-route.test.mjs mindspace-publications.test.mjs mindspace-public-links.test.mjs mindspace-chat-save.test.mjs mindspace-chat-docx-package.test.mjs mindspace-public-finish-sync.test.mjs mindspace-chat-context.test.mjs mindspace-canonical-url.test.mjs mindspace-conversation-package.test.mjs mindspace-conversation-package-backfill.test.mjs mindspace-conversation-package-public-html.test.mjs mindspace-conversation-package-verify.test.mjs mindspace-conversation-package-registry.test.mjs mindspace-conversation-package-routes.test.mjs mindspace-conversation-package-store.test.mjs mindspace-conversation-schema.test.mjs mindspace-runtime-config.test.mjs mindspace-config.test.mjs mindspace-analytics.test.mjs mindspace-service.test.mjs mindspace-storage-adapter.test.mjs mindspace-content-scan.test.mjs mindspace-html-localize.test.mjs mindspace-visual-editor.test.mjs mindspace-cleanup.test.mjs mindspace-thumbnails.test.mjs mindspace-workspace-thumbnails.test.mjs mindspace-workspace-sync.test.mjs mindspace-asset-preview.test.mjs mindspace-agent-jobs.test.mjs mindspace-agent-runner.test.mjs mindspace-sandbox-mcp.test.mjs user-data-space-service.test.mjs page-data-routes.test.mjs page-access-policy.test.mjs page-access-visitor.test.mjs page-data-public-service.test.mjs page-data-integration.test.mjs page-data-log-store.test.mjs page-data-ops.test.mjs page-data-session-store.test.mjs page-data-browser-client.test.mjs page-data-policy-index.test.mjs message-stream.test.mjs mindspace-service/mindspace-rpc-server.test.mjs plaza-posts.test.mjs plaza-interactions.test.mjs plaza-algorithm.test.mjs plaza-seo.test.mjs plaza-ops.test.mjs user-auth.test.mjs llm-providers.test.mjs admin-guard.test.mjs user-feedback.test.mjs memory-v2.test.mjs memory-v2-admin-config.test.mjs memory-v2-lifecycle.test.mjs memory-v2-adapter-scaffold.test.mjs memory-v2-backend-contract.test.mjs memory-v2-health.test.mjs memory-v2-runtime.test.mjs memory-v2-plugin-backends.test.mjs memory-v2-pgvector.test.mjs memory-v2-pgvector-schema.test.mjs memory-v2-pgvector-backfill.test.mjs memory-v2-pgvector-smoke.test.mjs memory-v2-qdrant.test.mjs memory-v2-weaviate.test.mjs memory-v2-mem0.test.mjs memory-v2-letta.test.mjs memory-v2-external-adapters.test.mjs scripts/embed-memory-v2-local-hash.test.mjs scripts/check-memory-v2-app-canary.test.mjs scripts/check-memory-v2-config-gaps.test.mjs scripts/check-memory-v2-contracts.test.mjs scripts/check-memory-v2-health.test.mjs scripts/check-memory-v2-session-flow.test.mjs scripts/check-memory-v2-stack.test.mjs scripts/setup-memory-v2-pgvector-schema.test.mjs scripts/backfill-memory-v2-pgvector.test.mjs scripts/scaffold-memory-v2-backend.test.mjs scripts/smoke-memory-v2-pgvector.test.mjs scripts/smoke-memory-v2-qdrant.test.mjs scripts/smoke-memory-v2-external.test.mjs scripts/mock-memory-v2-services.test.mjs", "test:mindspace-service": "node --test mindspace-service/mindspace-rpc-server.test.mjs", "verify:chat-finish-sync": "node scripts/verify-chat-finish-sync.mjs", "verify:public-finish-sync-runtime": "node scripts/verify-public-finish-sync-runtime.mjs", diff --git a/server.mjs b/server.mjs index b4da6c0..df10f16 100644 --- a/server.mjs +++ b/server.mjs @@ -38,13 +38,14 @@ 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 { injectMindSpaceAnalytics, resolveAnalyticsOwnerLabel, resolveAnalyticsOwnerSegment, resolveMindSpaceAnalyticsConfig, 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'; import { createMindSpaceAuditWriter } from './mindspace-audit.mjs'; import { assertMindSpaceRoute, mindspaceFlags } from './mindspace-flags.mjs'; import { createMindSpaceService } from './mindspace.mjs'; -import { ensureMindSpaceConfig } from './mindspace-config.mjs'; +import { ensureMindSpaceConfig, loadMindSpaceConfig } from './mindspace-config.mjs'; import { createMindSearchConfigService } from './mindsearch-config.mjs'; import { pageInternals, inlinePrivateAssetsInHtml, normalizeWorkspaceRelativePath } from './mindspace-pages.mjs'; import { @@ -404,6 +405,7 @@ let wordFilterService = null; let authPool = null; let pageDataService = null; let pageDataPublicService = null; +let mindSpaceAnalyticsConfig = resolveMindSpaceAnalyticsConfig(); async function bootstrapUserAuth() { try { @@ -416,6 +418,19 @@ async function bootstrapUserAuth() { await ensureMindSpaceConfig(pool, { env: process.env, }); + const storedMindSpaceConfig = await loadMindSpaceConfig(pool, { + env: process.env, + includeAnalyticsSecret: true, + }); + if (storedMindSpaceConfig?.analytics) { + mindSpaceAnalyticsConfig = { + ...mindSpaceAnalyticsConfig, + ...storedMindSpaceConfig.analytics, + enabled: Boolean(storedMindSpaceConfig.analytics.enabled && storedMindSpaceConfig.analytics.websiteId && storedMindSpaceConfig.analytics.idSecret), + hostPath: '/analytics', + scriptPath: '/analytics/script.js', + }; + } scheduleService = createScheduleService(pool, { defaultTimezone: process.env.H5_DEFAULT_TIMEZONE || 'Asia/Shanghai', }); @@ -808,6 +823,22 @@ async function bootstrapUserAuth() { scheduleService: process.env.H5_SCHEDULE_ENABLED === '1' ? scheduleService : null, wechatScheduleLlmConfigService, llmProviderService, + onPageGenerated: async ({ userId, sessionId, artifacts = [] }) => { + for (const artifact of artifacts) { + void sendMindSpaceAnalyticsEvent({ + config: mindSpaceAnalyticsConfig, + eventName: 'page_generated', + ownerId: userId, + ownerSegment: resolveAnalyticsOwnerSegment(await userAuth?.getUserById(userId).catch(() => null) ?? {}), + ownerLabel: resolveAnalyticsOwnerLabel(await userAuth?.getUserById(userId).catch(() => null) ?? {}), + pageId: artifact.relativePath, + publicationId: sessionId, + agentRunId: sessionId, + channel: 'wechat_mp', + url: artifact.url || artifact.relativePath || '/', + }); + } + }, applySessionLlmProvider: (sessionId) => tkmindProxy.applySessionLlmProvider(sessionId), refreshSessionSnapshot: sessionSnapshotService?.isEnabled() @@ -5155,6 +5186,7 @@ api.get('/sessions/:sessionId/events', async (req, res, next) => { // Retain every in-flight contract write and await it before marking files // deliverable after Finish. const deliveryContractWrites = new Map(); + const generationAnalyticsEvents = new Set(); const syncPublicHtmlDuringStream = (event) => { const paths = collectPublicHtmlWritePathsFromSessionEvent(event, { publishDir }); const eventMessages = event?.type === 'Message' && event.message @@ -5180,6 +5212,23 @@ api.get('/sessions/:sessionId/events', async (req, res, next) => { deliveryContractWrites.set(relativePath, write); } materializePublicHtmlWritesFromSessionEvent(event, { publishDir }); + for (const relativePath of paths) { + const absolutePath = path.resolve(publishDir, relativePath); + if (generationAnalyticsEvents.has(relativePath) || !fs.existsSync(absolutePath)) continue; + generationAnalyticsEvents.add(relativePath); + void sendMindSpaceAnalyticsEvent({ + config: mindSpaceAnalyticsConfig, + eventName: 'page_generated', + ownerId: req.currentUser.id, + ownerSegment: resolveAnalyticsOwnerSegment(req.currentUser), + ownerLabel: resolveAnalyticsOwnerLabel(req.currentUser), + pageId: relativePath, + publicationId: sid, + agentRunId: sid, + channel: 'h5', + url: `/${PUBLISH_ROOT_DIR}/${req.currentUser.id}/${relativePath}`, + }); + } }; // After Finish, refresh the snapshot and persist any newly generated public // workspace HTML into the asset store before a later restart rebuilds the @@ -5391,6 +5440,13 @@ api.use( ); app.use('/api', api); +app.use('/analytics', createProxyMiddleware({ + target: 'http://127.0.0.1:3100', + router: () => mindSpaceAnalyticsConfig.analyticsUrl || process.env.MEMIND_ANALYTICS_URL || 'http://127.0.0.1:3100', + changeOrigin: true, + secure: false, + pathRewrite: { [`^${mindSpaceAnalyticsConfig.hostPath}`]: '' }, + })); // Express routing is case-insensitive by default, so the lowercase /mindspace API // mount would otherwise capture public /MindSpace/... page URLs. app.use(`/${PUBLISH_ROOT_DIR}`, async (req, res, next) => { @@ -6226,12 +6282,15 @@ async function sendPublishFile(req, res, filePath, { isOwner = true } = {}) { filePath, thumbnailPngPathForSvg, }); + const parsedPublishPath = parseMindSpacePublishFilePath(filePath, __dirname); + const pageOwner = parsedPublishPath?.userId && userAuth + ? await userAuth.getUserById(parsedPublishPath.userId).catch(() => null) + : null; let pageDataContext = null; if (mindSpacePages) { - const parsed = parseMindSpacePublishFilePath(filePath, __dirname); - if (parsed?.userId && parsed.relativePath) { + if (parsedPublishPath?.userId && parsedPublishPath.relativePath) { const page = await mindSpacePages - .findPageByRelativePath(parsed.userId, parsed.relativePath) + .findPageByRelativePath(parsedPublishPath.userId, parsedPublishPath.relativePath) .catch(() => null); if (page?.id) { pageDataContext = { @@ -6241,6 +6300,14 @@ async function sendPublishFile(req, res, filePath, { isOwner = true } = {}) { } } } + html = injectMindSpaceAnalytics(html, { + ownerId: parsedPublishPath?.userId ?? '', + ownerSegment: resolveAnalyticsOwnerSegment(pageOwner ?? {}), + ownerLabel: resolveAnalyticsOwnerLabel(pageOwner ?? {}), + pageId: pageDataContext?.pageId ?? '', + publicationId: pageDataContext?.publicationId ?? pageDataContext?.publication_id ?? '', + config: mindSpaceAnalyticsConfig, + }); const decorated = decorateMindSpacePublishedHtml({ html, embed, diff --git a/wechat-mp.mjs b/wechat-mp.mjs index 30f93ed..747b24b 100644 --- a/wechat-mp.mjs +++ b/wechat-mp.mjs @@ -888,6 +888,9 @@ function resolveHtmlPublishArtifacts({ workingDir, publicBaseUrl, requestStartedAt, + userId = '', + sessionId = '', + onPageGenerated = null, }) { materializeMissingPublicHtmlWrites({ messages: reply?.messages ?? [], @@ -918,6 +921,9 @@ function resolveHtmlPublishArtifacts({ recentArtifacts, replyText: reply?.text, }); + if (typeof onPageGenerated === 'function' && confirmedArtifacts.length > 0) { + void onPageGenerated({ userId, sessionId, artifacts: confirmedArtifacts }); + } return { publishedArtifacts, expectedArtifacts, @@ -1406,6 +1412,7 @@ export function createWechatMpService({ scheduleService = null, wechatScheduleLlmConfigService = null, llmProviderService = null, + onPageGenerated = null, applySessionLlmProvider = null, refreshSessionSnapshot = null, pageDataFinishGuard = null, @@ -1979,6 +1986,9 @@ export function createWechatMpService({ workingDir, publicBaseUrl: config.publicBaseUrl, requestStartedAt, + userId: user.userId, + sessionId, + onPageGenerated, }); const hasValidLinkInReply = await hasAnyValidPublishedHtmlLink(reply?.text, linkExistsForRequest, { confirmedArtifacts, @@ -2135,6 +2145,9 @@ export function createWechatMpService({ workingDir, publicBaseUrl: config.publicBaseUrl, requestStartedAt: retryStartedAt, + userId: user.userId, + sessionId, + onPageGenerated, }); const hasValidLinkInReply = await hasAnyValidPublishedHtmlLink(reply?.text, linkExistsForRequest, { confirmedArtifacts,