feat(mindspace): add opt-in local analytics

This commit is contained in:
john
2026-07-16 21:06:28 +08:00
parent 6e460336f7
commit 320945a485
9 changed files with 412 additions and 6 deletions
+71 -4
View File
@@ -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,