feat(mindspace): add opt-in local analytics
This commit is contained in:
@@ -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=<local Umami Website ID>
|
||||
# MEMIND_ANALYTICS_ID_SECRET=<local-only pseudonymization 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
|
||||
|
||||
@@ -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=<website-id>
|
||||
MEMIND_ANALYTICS_ID_SECRET=<random-local-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.
|
||||
@@ -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*(<!doctype html|<html\b)/i.test(source)) return source;
|
||||
if (source.includes(ANALYTICS_MARKER)) return source;
|
||||
const owner = pseudonymizeAnalyticsId(ownerId, config.idSecret);
|
||||
if (!owner) return source;
|
||||
const metadata = { owner_id: owner, owner_segment: String(ownerSegment || 'unknown'), owner_label: resolveAnalyticsOwnerLabel({ displayName: ownerLabel }), page_id: String(pageId || ''), publication_id: String(publicationId || ''), channel };
|
||||
const attrs = [
|
||||
ANALYTICS_MARKER,
|
||||
`data-website-id="${config.websiteId.replaceAll('"', '"')}"`,
|
||||
'data-auto-track="false"',
|
||||
`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>`;
|
||||
if (/<\/head>/i.test(source)) return source.replace(/<\/head>/i, `${block}</head>`);
|
||||
return source.replace(/<body\b/i, `${block}<body`);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
injectMindSpaceAnalytics,
|
||||
pseudonymizeAnalyticsId,
|
||||
resolveAnalyticsOwnerSegment,
|
||||
resolveAnalyticsOwnerLabel,
|
||||
resolveMindSpaceAnalyticsConfig,
|
||||
sendMindSpaceAnalyticsEvent,
|
||||
} from './mindspace-analytics.mjs';
|
||||
|
||||
test('analytics config is disabled unless explicitly enabled and configured', () => {
|
||||
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 = '<!doctype html><html><head><title>Demo</title></head><body><h1>Demo</h1></body></html>';
|
||||
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 = '<div>hello</div>';
|
||||
assert.equal(injectMindSpaceAnalytics(fragment, { ownerId: 'u', config: { enabled: true, websiteId: 'w', idSecret: 's' } }), fragment);
|
||||
const html = '<!doctype html><html><head></head><body></body></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);
|
||||
});
|
||||
+59
-1
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
+71
-4
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user