diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4ff2a932..34791432 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -174,7 +174,8 @@ jobs: APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} # 分析服务 - APTABASE_APP_KEY: ${{ secrets.APTABASE_APP_KEY }} + UMAMI_ENDPOINT: ${{ secrets.UMAMI_ENDPOINT }} + UMAMI_WEBSITE_ID: ${{ secrets.UMAMI_WEBSITE_ID }} working-directory: apps/desktop run: pnpm run build && pnpm exec electron-builder --mac --${{ matrix.arch }} --config electron-builder.yml -p never @@ -286,7 +287,8 @@ jobs: env: GH_TOKEN: ${{ secrets.GH_TOKEN }} # 分析服务 - APTABASE_APP_KEY: ${{ secrets.APTABASE_APP_KEY }} + UMAMI_ENDPOINT: ${{ secrets.UMAMI_ENDPOINT }} + UMAMI_WEBSITE_ID: ${{ secrets.UMAMI_WEBSITE_ID }} working-directory: apps/desktop run: pnpm run build:win @@ -354,7 +356,8 @@ jobs: - name: Build CLI (with Web UI) env: - APTABASE_APP_KEY: ${{ secrets.APTABASE_APP_KEY }} + UMAMI_ENDPOINT: ${{ secrets.UMAMI_ENDPOINT }} + UMAMI_WEBSITE_ID: ${{ secrets.UMAMI_WEBSITE_ID }} run: pnpm --filter chatlab-cli run build:full - name: Upload CLI build output diff --git a/apps/cli/src/http/routes/web/import.test.ts b/apps/cli/src/http/routes/web/import.test.ts index 863527cf..c0ab8795 100644 --- a/apps/cli/src/http/routes/web/import.test.ts +++ b/apps/cli/src/http/routes/web/import.test.ts @@ -25,6 +25,34 @@ function multipartPayload(): { payload: Buffer; contentType: string } { } } +function directoryMultipartPayload(): { payload: Buffer; contentType: string } { + const boundary = '----chatlab-directory-import-route-test' + const document = JSON.stringify({ + chatlab: { version: '0.0.2', exportedAt: 1711468800 }, + meta: { name: 'Test Chat', platform: 'line', type: 'group' }, + members: [{ platformId: 'u1', accountName: 'Alice' }], + messages: [{ sender: 'u1', accountName: 'Alice', timestamp: 1711468800, type: 0, content: 'hello' }], + }) + const relativePath = [ + `--${boundary}`, + 'Content-Disposition: form-data; name="relativePaths"', + '', + 'export/chat.json', + ].join('\r\n') + const file = [ + `--${boundary}`, + 'Content-Disposition: form-data; name="files"; filename="chat.json"', + 'Content-Type: application/json', + '', + document, + ].join('\r\n') + + return { + payload: Buffer.from([relativePath, file, `--${boundary}--`, ''].join('\r\n')), + contentType: `multipart/form-data; boundary=${boundary}`, + } +} + describe('CLI Web automatic import route', () => { it('forwards parser options and preserves a zero-new incremental result in the done event', async () => { const app = Fastify() @@ -101,6 +129,72 @@ describe('CLI Web automatic import route', () => { } }) + it('preserves the parsed platform in a failed created-import event', async () => { + const app = Fastify() + try { + await app.register(multipart) + registerImportRoutes( + app, + {} as any, + { + runAutoImport: async () => ({ + success: false, + platform: 'line', + error: 'database unavailable', + }), + } as any + ) + + const body = multipartPayload() + const response = await app.inject({ + method: 'POST', + url: '/_web/import', + headers: { 'content-type': body.contentType }, + payload: body.payload, + }) + + assert.equal(response.statusCode, 200) + assert.match(response.body, /event: error/) + assert.match(response.body, /"platform":"line"/) + assert.match(response.body, /"error":"database unavailable"/) + } finally { + await app.close() + } + }) + + it('preserves the parsed platform in a failed directory-import event', async () => { + const app = Fastify() + try { + await app.register(multipart) + registerImportRoutes( + app, + {} as any, + { + runAutoImport: async () => ({ + success: false, + platform: 'line', + error: 'database unavailable', + }), + } as any + ) + + const body = directoryMultipartPayload() + const response = await app.inject({ + method: 'POST', + url: '/_web/import-directory', + headers: { 'content-type': body.contentType }, + payload: body.payload, + }) + + assert.equal(response.statusCode, 200) + assert.match(response.body, /event: error/) + assert.match(response.body, /"platform":"line"/) + assert.match(response.body, /"error":"database unavailable"/) + } finally { + await app.close() + } + }) + it('preserves an ambiguous create reason in the done event', async () => { const app = Fastify() try { diff --git a/apps/cli/src/http/routes/web/import.ts b/apps/cli/src/http/routes/web/import.ts index 63e13cbb..02ce45e7 100644 --- a/apps/cli/src/http/routes/web/import.ts +++ b/apps/cli/src/http/routes/web/import.ts @@ -230,7 +230,7 @@ export function registerImportRoutes( if (result.success) { sendEvent('done', result) } else { - sendEvent('error', { success: false, error: result.error }) + sendEvent('error', result) } } catch (err) { sendEvent('error', { success: false, error: err instanceof Error ? err.message : String(err) }) @@ -299,7 +299,7 @@ export function registerImportRoutes( if (result.success) { sendEvent('done', result) } else { - sendEvent('error', { success: false, error: result.error }) + sendEvent('error', result) } } catch (err) { if (!reply.raw.headersSent) { diff --git a/apps/cli/src/http/routes/web/index.ts b/apps/cli/src/http/routes/web/index.ts index a67456d3..4fabf720 100644 --- a/apps/cli/src/http/routes/web/index.ts +++ b/apps/cli/src/http/routes/web/index.ts @@ -116,9 +116,11 @@ export function registerWebRoutes( const semanticIndexService = options?.semanticIndexService - const analyticsService = process.env.APTABASE_APP_KEY - ? new AnalyticsService(resolvedPathProvider.getSystemDir(), process.env.APTABASE_APP_KEY, getVersion()) - : undefined + const analyticsService = new AnalyticsService(resolvedPathProvider.getSystemDir(), { + appVersion: getVersion(), + appType: 'cli_web', + getAiModelConfigured: () => ai?.llmConfigStore.hasConfiguredModel() === true, + }) registerSharedRoutes( server, diff --git a/apps/cli/src/import/stream-import.test.ts b/apps/cli/src/import/stream-import.test.ts index 75a83e8b..61af7032 100644 --- a/apps/cli/src/import/stream-import.test.ts +++ b/apps/cli/src/import/stream-import.test.ts @@ -135,7 +135,7 @@ test('incremental analysis and import honor an explicitly selected parser format }) const result = await incrementalImport(manager, 'existing', filePath, { formatId: 'chatlab-jsonl' }) - assert.deepEqual(analysis, { newMessageCount: 1, duplicateCount: 0, totalInFile: 1 }) + assert.deepEqual(analysis, { newMessageCount: 1, duplicateCount: 0, totalInFile: 1, platform: 'qq' }) assert.equal(result.success, true) assert.equal(result.newMessageCount, 1) }) diff --git a/apps/cli/src/release-workflow.test.ts b/apps/cli/src/release-workflow.test.ts index 960d4499..ccf3b2c7 100644 --- a/apps/cli/src/release-workflow.test.ts +++ b/apps/cli/src/release-workflow.test.ts @@ -14,7 +14,7 @@ function extractSection(source: string, startMarker: string, endMarker: string): return source.slice(start, end) } -test('release CLI build injects the Aptabase app key before tsup inlines env values', () => { +test('release CLI build injects Umami configuration before tsup inlines env values', () => { const buildCliJob = extractSection(releaseWorkflow, ' build-cli:', ' release:') const buildCliStep = extractSection( buildCliJob, @@ -23,5 +23,6 @@ test('release CLI build injects the Aptabase app key before tsup inlines env val ) assert.match(buildCliStep, /run: pnpm --filter chatlab-cli run build:full/) - assert.match(buildCliStep, /APTABASE_APP_KEY: \$\{\{ secrets\.APTABASE_APP_KEY \}\}/) + assert.match(buildCliStep, /UMAMI_ENDPOINT: \$\{\{ secrets\.UMAMI_ENDPOINT \}\}/) + assert.match(buildCliStep, /UMAMI_WEBSITE_ID: \$\{\{ secrets\.UMAMI_WEBSITE_ID \}\}/) }) diff --git a/apps/cli/tsup.config.ts b/apps/cli/tsup.config.ts index e104a9a9..d26d3b45 100644 --- a/apps/cli/tsup.config.ts +++ b/apps/cli/tsup.config.ts @@ -18,7 +18,8 @@ export default defineConfig({ target: 'node20', platform: 'node', define: { - 'process.env.APTABASE_APP_KEY': JSON.stringify(process.env.APTABASE_APP_KEY || ''), + 'process.env.UMAMI_ENDPOINT': JSON.stringify(process.env.UMAMI_ENDPOINT || ''), + 'process.env.UMAMI_WEBSITE_ID': JSON.stringify(process.env.UMAMI_WEBSITE_ID || ''), }, noExternal: [/^@openchatlab\/(?!parser-native)/, 'chatlab-mcp', 'stream-json'], // parser-native 是本地构建的可选 Rust 内核:不打包也不声明依赖, diff --git a/apps/desktop/electron.vite.config.ts b/apps/desktop/electron.vite.config.ts index 00d4ccd0..bba34d67 100644 --- a/apps/desktop/electron.vite.config.ts +++ b/apps/desktop/electron.vite.config.ts @@ -31,7 +31,8 @@ export default defineConfig({ }, define: { __APP_VERSION__: JSON.stringify(appVersion), - 'process.env.APTABASE_APP_KEY': JSON.stringify(process.env.APTABASE_APP_KEY || ''), + 'process.env.UMAMI_ENDPOINT': JSON.stringify(process.env.UMAMI_ENDPOINT || ''), + 'process.env.UMAMI_WEBSITE_ID': JSON.stringify(process.env.UMAMI_WEBSITE_ID || ''), // ws 的原生加速依赖是可选项;主进程打包时禁用它们,避免 Vite 将缺失的可选依赖改写为启动即抛错。 'process.env.WS_NO_BUFFER_UTIL': JSON.stringify('true'), 'process.env.WS_NO_UTF_8_VALIDATE': JSON.stringify('true'), diff --git a/apps/desktop/main/analytics.ts b/apps/desktop/main/analytics.ts index a95153b8..9962c381 100644 --- a/apps/desktop/main/analytics.ts +++ b/apps/desktop/main/analytics.ts @@ -1,23 +1,24 @@ import { app, ipcMain } from 'electron' -import { AnalyticsService } from '@openchatlab/node-runtime' +import { AnalyticsService, appLogger } from '@openchatlab/node-runtime' +import type { AnalyticsEventName } from '@openchatlab/shared-types' +import { getDesktopLlmRuntimeStores } from './ai/llm' import { getSystemDataDir } from './paths/locations' -const APTABASE_APP_KEY = process.env.APTABASE_APP_KEY - let _service: AnalyticsService | null = null function getService(): AnalyticsService | null { - if (!APTABASE_APP_KEY) return null if (!_service) { const systemDir = getSystemDataDir() - _service = new AnalyticsService(systemDir, APTABASE_APP_KEY, app.getVersion()) + _service = new AnalyticsService(systemDir, { + appVersion: app.getVersion(), + appType: 'desktop', + getAiModelConfigured: () => getDesktopLlmRuntimeStores().llmConfigStore.hasConfiguredModel(), + }) } return _service } export function initAnalytics(): void { - // Service is initialized lazily; no-op here unless we want eager validation. - if (!APTABASE_APP_KEY) return getService() } @@ -33,13 +34,19 @@ export function registerAnalyticsHandlers(): void { ipcMain.handle('analytics:trackDailyActive', (_, locale: string) => { getService() - ?.trackDailyActive({ platform: 'desktop', locale }) - .catch((e) => console.error('[Analytics] Failed to report daily active:', e)) + ?.trackDailyActive({ app_locale: locale }) + .catch((error) => appLogger.error('analytics', 'Failed to report daily active event', error)) + }) + + ipcMain.handle('analytics:track', (_, eventName: AnalyticsEventName, properties?: Record) => { + getService() + ?.track(eventName, properties) + .catch((error) => appLogger.error('analytics', `Failed to report ${eventName} event`, error)) }) } -export function trackAppEvent(eventName: string, properties?: Record): void { +export function trackAppEvent(eventName: AnalyticsEventName, properties?: Record): void { getService() ?.track(eventName, properties) - .catch((e) => console.error(`[Analytics] Failed to report event ${eventName}:`, e)) + .catch((error) => appLogger.error('analytics', `Failed to report ${eventName} event`, error)) } diff --git a/apps/desktop/main/worker/workerManager.ts b/apps/desktop/main/worker/workerManager.ts index 4c316471..1105accc 100644 --- a/apps/desktop/main/worker/workerManager.ts +++ b/apps/desktop/main/worker/workerManager.ts @@ -997,6 +997,7 @@ export interface IncrementalAnalyzeResult { newMessageCount: number duplicateCount: number totalInFile: number + platform?: string error?: string } diff --git a/apps/desktop/preload/apis/chat.ts b/apps/desktop/preload/apis/chat.ts index 5040c681..389eddd2 100644 --- a/apps/desktop/preload/apis/chat.ts +++ b/apps/desktop/preload/apis/chat.ts @@ -10,6 +10,7 @@ import type { ImportProgress } from '../../../../src/types/base' interface ChatImportResult { success: boolean sessionId?: string + platform?: string error?: string importMode?: 'created' | 'incremental' matchedBy?: 'source-session-id' | 'stable-id' | 'trailing-messages' @@ -90,6 +91,7 @@ export const chatApi = { newMessageCount: number duplicateCount: number totalInFile: number + platform?: string error?: string diagnosis?: { suggestion?: string } }> => ipcRenderer.invoke('chat:analyzeIncrementalImport', sessionId, filePath), diff --git a/apps/desktop/preload/apis/core.ts b/apps/desktop/preload/apis/core.ts index 7719ed49..8a5246e5 100644 --- a/apps/desktop/preload/apis/core.ts +++ b/apps/desktop/preload/apis/core.ts @@ -2,6 +2,7 @@ * 核心 API - 基础 IPC 通信和系统功能 */ import { ipcRenderer } from 'electron' +import type { AnalyticsEventName } from '@openchatlab/shared-types' // Custom APIs for renderer export const api = { @@ -95,6 +96,9 @@ export const extendedApi = { trackDailyActive: (locale: string): Promise => { return ipcRenderer.invoke('analytics:trackDailyActive', locale) }, + trackAnalyticsEvent: (eventName: AnalyticsEventName, properties?: Record): Promise => { + return ipcRenderer.invoke('analytics:track', eventName, properties) + }, /** * 重启应用 */ diff --git a/apps/desktop/preload/index.d.ts b/apps/desktop/preload/index.d.ts index dc91f89e..b76862d3 100644 --- a/apps/desktop/preload/index.d.ts +++ b/apps/desktop/preload/index.d.ts @@ -1,7 +1,7 @@ import { ElectronAPI } from '@electron-toolkit/preload' import type { ImportProgress, ExportProgress } from '../../../src/types/base' import type { TokenUsage, AgentRuntimeStatus, SerializedErrorInfo, SecurityApi } from '../shared/types' -import type { TimeFilter } from '@openchatlab/shared-types' +import type { AnalyticsEventName, TimeFilter } from '@openchatlab/shared-types' // 迁移相关类型 interface MigrationInfo { @@ -43,6 +43,7 @@ interface ImportDiagnostics { interface ChatImportResult { success: boolean sessionId?: string + platform?: string error?: string importMode?: 'created' | 'incremental' matchedBy?: 'source-session-id' | 'stable-id' | 'trailing-messages' @@ -96,6 +97,7 @@ interface ChatApi { newMessageCount: number duplicateCount: number totalInFile: number + platform?: string error?: string diagnosis?: { suggestion?: string } }> @@ -134,6 +136,7 @@ interface Api { getAnalyticsEnabled: () => Promise setAnalyticsEnabled: (enabled: boolean) => Promise<{ success: boolean }> trackDailyActive: (locale: string) => Promise + trackAnalyticsEvent: (eventName: AnalyticsEventName, properties?: Record) => Promise relaunch: () => Promise getOpenAtLogin: () => Promise setOpenAtLogin: (enabled: boolean) => Promise<{ success: boolean; error?: string }> diff --git a/packages/http-routes/src/routes/web/telemetry.test.ts b/packages/http-routes/src/routes/web/telemetry.test.ts index a0d28388..869fe902 100644 --- a/packages/http-routes/src/routes/web/telemetry.test.ts +++ b/packages/http-routes/src/routes/web/telemetry.test.ts @@ -39,4 +39,42 @@ describe('telemetry routes', () => { await app.close() } }) + + it('forwards typed events and properties to the shared analytics service', async () => { + const tracked: Array<{ eventName: string; properties?: Record }> = [] + const app = Fastify() + registerTelemetryRoutes(app, { + analyticsService: { + getEnabled: () => true, + setEnabled: () => {}, + trackDailyActive: async () => {}, + track: async (eventName, properties) => { + tracked.push({ eventName, properties }) + return true + }, + }, + }) + + await app.ready() + try { + const response = await app.inject({ + method: 'POST', + url: '/_web/telemetry/track', + payload: { + eventName: 'feature_used', + properties: { feature_id: 'insights', ignored: 'value' }, + }, + }) + + assert.equal(response.statusCode, 200) + assert.deepEqual(tracked, [ + { + eventName: 'feature_used', + properties: { feature_id: 'insights', ignored: 'value' }, + }, + ]) + } finally { + await app.close() + } + }) }) diff --git a/packages/http-routes/src/routes/web/telemetry.ts b/packages/http-routes/src/routes/web/telemetry.ts index bf125d3d..f22eee49 100644 --- a/packages/http-routes/src/routes/web/telemetry.ts +++ b/packages/http-routes/src/routes/web/telemetry.ts @@ -1,5 +1,6 @@ import type { FastifyInstance } from 'fastify' import type { AnalyticsService } from '@openchatlab/node-runtime' +import type { AnalyticsEventName } from '@openchatlab/shared-types' interface TelemetryRouteContext { analyticsService?: Pick @@ -16,7 +17,7 @@ export function registerTelemetryRoutes(server: FastifyInstance, ctx: TelemetryR return { success: true } }) - server.post<{ Body: { eventName: string; properties?: Record } }>( + server.post<{ Body: { eventName: AnalyticsEventName; properties?: Record } }>( '/_web/telemetry/track', async (req, reply) => { if (!ctx.analyticsService) return { ok: true } diff --git a/packages/node-runtime/src/ai/__tests__/llm-config-store.test.ts b/packages/node-runtime/src/ai/__tests__/llm-config-store.test.ts index 4f52a607..2a1b1fb2 100644 --- a/packages/node-runtime/src/ai/__tests__/llm-config-store.test.ts +++ b/packages/node-runtime/src/ai/__tests__/llm-config-store.test.ts @@ -32,6 +32,17 @@ describe('LLMConfigStore', () => { const all = store.getAllConfigs() assert.equal(all.length, 0) assert.equal(store.hasActiveConfig(), false) + assert.equal(store.hasConfiguredModel(), false) + }) + + it('only reports a configured model after the active slot has a model id', () => { + const result = store.addConfig({ name: 'A', provider: 'openai', apiKey: 'k' }) + assert.equal(result.success, true) + assert.equal(store.hasActiveConfig(), true) + assert.equal(store.hasConfiguredModel(), false) + + store.setDefaultAssistantModel(result.config!.id, 'gpt-4') + assert.equal(store.hasConfiguredModel(), true) }) it('does not expose an unmigrated encrypted API key as a usable key', () => { diff --git a/packages/node-runtime/src/ai/llm-config-store.ts b/packages/node-runtime/src/ai/llm-config-store.ts index 83634834..07b08ae6 100644 --- a/packages/node-runtime/src/ai/llm-config-store.ts +++ b/packages/node-runtime/src/ai/llm-config-store.ts @@ -330,4 +330,9 @@ export class LLMConfigStore { hasActiveConfig(): boolean { return this.getDefaultAssistantConfig() !== null } + + hasConfiguredModel(): boolean { + const config = this.getDefaultAssistantConfig() + return typeof config?.model === 'string' && config.model.trim().length > 0 + } } diff --git a/packages/node-runtime/src/import/auto-importer.test.ts b/packages/node-runtime/src/import/auto-importer.test.ts index aea47448..59a21b6b 100644 --- a/packages/node-runtime/src/import/auto-importer.test.ts +++ b/packages/node-runtime/src/import/auto-importer.test.ts @@ -13,8 +13,11 @@ function createDeps(options?: { decision?: AutoImportDecision matchError?: Error createdSessionId?: string + createError?: string incrementalNewCount?: number incrementalDuplicateCount?: number + incrementalError?: string + incrementalThrow?: Error }) { const calls = { match: 0, @@ -36,9 +39,17 @@ function createDeps(options?: { }, createSession: async (filePath, _formatOptions, sessionId) => { calls.create.push({ filePath, sessionId }) + if (options?.createError) { + return { + success: false, + platform: 'line', + error: options.createError, + } + } return { success: true, sessionId: sessionId ?? options?.createdSessionId ?? 'created-session', + platform: 'qq', diagnostics: { logFile: null, detectedFormat: 'fixture', @@ -52,6 +63,10 @@ function createDeps(options?: { }, appendSession: async (sessionId, filePath) => { calls.append.push({ sessionId, filePath }) + if (options?.incrementalThrow) throw options.incrementalThrow + if (options?.incrementalError) { + return { success: false, newMessageCount: 0, error: options.incrementalError } + } const newMessageCount = options?.incrementalNewCount ?? 3 return { success: true, @@ -189,6 +204,47 @@ test('automatic unique match preserves incremental mode when every message is du assert.deepEqual(calls.create, []) }) +test('automatic incremental result preserves its mode when append returns a failure', async () => { + const { deps } = createDeps({ + decision: { action: 'incremental', sessionId: 'existing', matchedBy: 'stable-id' }, + incrementalError: 'write failed', + }) + + assert.deepEqual(await autoImportFile('source.json', deps), { + success: false, + sessionId: 'existing', + importMode: 'incremental', + matchedBy: 'stable-id', + error: 'write failed', + }) +}) + +test('automatic incremental result preserves its mode when append throws', async () => { + const { deps } = createDeps({ + decision: { action: 'incremental', sessionId: 'existing', matchedBy: 'trailing-messages' }, + incrementalThrow: new Error('database unavailable'), + }) + + assert.deepEqual(await autoImportFile('source.json', deps), { + success: false, + sessionId: 'existing', + importMode: 'incremental', + matchedBy: 'trailing-messages', + error: 'database unavailable', + }) +}) + +test('automatic created result preserves the parsed platform when import fails', async () => { + const { deps } = createDeps({ createError: 'database unavailable' }) + + assert.deepEqual(await autoImportFile('source.json', deps), { + success: false, + platform: 'line', + error: 'database unavailable', + diagnostics: undefined, + }) +}) + for (const reason of ['no-match', 'ambiguous'] as const) { test(`automatic ${reason} decision creates a new session`, async () => { const { deps, calls } = createDeps({ decision: { action: 'create', reason } }) diff --git a/packages/node-runtime/src/import/auto-importer.ts b/packages/node-runtime/src/import/auto-importer.ts index 6151f8fd..017a4fa9 100644 --- a/packages/node-runtime/src/import/auto-importer.ts +++ b/packages/node-runtime/src/import/auto-importer.ts @@ -45,6 +45,7 @@ export interface AutoImportAnalysisDeps extends AutoImportMatcherDeps { export interface AutoImportResult { success: boolean sessionId?: string + platform?: string importMode?: 'created' | 'incremental' matchedBy?: AutoImportMatchMethod createReason?: AutoImportCreateReason @@ -103,11 +104,17 @@ async function planAutoImport( function mapCreateResult(result: StreamImportResult, createReason?: AutoImportCreateReason): AutoImportResult { if (!result.success || !result.sessionId) { - return { success: false, error: result.error, diagnostics: result.diagnostics } + return { + success: false, + ...(result.platform !== undefined ? { platform: result.platform } : {}), + error: result.error, + diagnostics: result.diagnostics, + } } return { success: true, sessionId: result.sessionId, + platform: result.platform, importMode: 'created', ...(createReason ? { createReason } : {}), newMessageCount: result.diagnostics?.messagesWritten ?? 0, @@ -128,12 +135,15 @@ function mapIncrementalResult( result: IncrementalImportResult, matchedBy?: AutoImportMatchMethod ): AutoImportResult { - if (!result.success) return { success: false, sessionId, error: result.error } + const incrementalResult = { + sessionId, + importMode: 'incremental' as const, + ...(matchedBy ? { matchedBy } : {}), + } + if (!result.success) return { success: false, ...incrementalResult, error: result.error } return { success: true, - sessionId, - importMode: 'incremental', - ...(matchedBy ? { matchedBy } : {}), + ...incrementalResult, newMessageCount: result.newMessageCount, duplicateCount: result.batch?.duplicateCount ?? 0, batch: result.batch, @@ -147,13 +157,14 @@ export async function autoImportFile( deps: AutoImportDeps, options: AutoImportOptions = {} ): Promise { + let plan: AutoImportPlan | undefined try { if (!options.explicitSessionId) { appLogger.info('import', 'Automatic session matching started', { candidateCount: deps.listSessionIds().length, }) } - const plan = await planAutoImport(filePath, deps, options) + plan = await planAutoImport(filePath, deps, options) if (plan.action === 'incremental') { const result = mapIncrementalResult( @@ -183,7 +194,17 @@ export async function autoImportFile( return result } catch (error) { appLogger.error('import', 'Automatic import failed', error) - return { success: false, error: error instanceof Error ? error.message : String(error) } + return { + success: false, + ...(plan?.action === 'incremental' + ? { + sessionId: plan.sessionId, + importMode: 'incremental' as const, + ...(plan.matchedBy ? { matchedBy: plan.matchedBy } : {}), + } + : {}), + error: error instanceof Error ? error.message : String(error), + } } } diff --git a/packages/node-runtime/src/import/incremental-importer.test.ts b/packages/node-runtime/src/import/incremental-importer.test.ts index 3d69587e..bc696bf0 100644 --- a/packages/node-runtime/src/import/incremental-importer.test.ts +++ b/packages/node-runtime/src/import/incremental-importer.test.ts @@ -126,6 +126,7 @@ test('imports ChatLab JSONL messages with numeric string timestamps consistently newMessageCount: 1, duplicateCount: 0, totalInFile: 1, + platform: 'wechat', }) const result = await incrementalImport('session', filePath, deps) @@ -287,6 +288,7 @@ test('deduplicates an ID-bearing copy of an existing fallback-only message', asy newMessageCount: 0, duplicateCount: 1, totalInFile: 1, + platform: 'wechat', }) const result = await incrementalImport('session', filePath, deps) diff --git a/packages/node-runtime/src/import/incremental-importer.ts b/packages/node-runtime/src/import/incremental-importer.ts index 2b3add53..9e2dc3aa 100644 --- a/packages/node-runtime/src/import/incremental-importer.ts +++ b/packages/node-runtime/src/import/incremental-importer.ts @@ -28,6 +28,7 @@ export interface IncrementalAnalyzeResult { newMessageCount: number duplicateCount: number totalInFile: number + platform?: string error?: string } @@ -169,12 +170,15 @@ export async function analyzeIncrementalImport( let totalInFile = 0 let newMessageCount = 0 let duplicateCount = 0 + let platform = formatFeature.platform await streamParseFile( filePath, { formatOptions: options?.chatIndex === undefined ? undefined : { chatIndex: options.chatIndex }, - onMeta: () => {}, + onMeta: (meta) => { + platform = meta.platform + }, onMembers: () => {}, onProgress: (progress: ParseProgress) => { deps.onProgress(progress) @@ -196,7 +200,7 @@ export async function analyzeIncrementalImport( options?.formatId ) - return { newMessageCount, duplicateCount, totalInFile } + return { newMessageCount, duplicateCount, totalInFile, platform } } // ==================== Execute incremental import ==================== diff --git a/packages/node-runtime/src/import/streaming-importer.test.ts b/packages/node-runtime/src/import/streaming-importer.test.ts index 1282218a..9e9a3d7e 100644 --- a/packages/node-runtime/src/import/streaming-importer.test.ts +++ b/packages/node-runtime/src/import/streaming-importer.test.ts @@ -324,6 +324,7 @@ test('streamingImport preserves an ordinary WhatsApp participant named SYSTEM', const result = await streamingImport(filePath, createImportDeps(dbPath), undefined, 'whatsapp-system-participant') assert.equal(result.success, true) + assert.equal(result.platform, 'whatsapp') const rawDb = new Database(dbPath, { readonly: true, nativeBinding }) const member = rawDb diff --git a/packages/node-runtime/src/import/streaming-importer.ts b/packages/node-runtime/src/import/streaming-importer.ts index 32f3a163..c6d004c4 100644 --- a/packages/node-runtime/src/import/streaming-importer.ts +++ b/packages/node-runtime/src/import/streaming-importer.ts @@ -49,6 +49,7 @@ export interface ImportDiagnostics { export interface StreamImportResult { success: boolean sessionId?: string + platform?: string error?: string diagnostics?: ImportDiagnostics } @@ -292,6 +293,7 @@ async function streamImportSingle( >() let metaInserted = false + let importedPlatform = formatFeature.platform let messageCountInBatch = 0 let totalMessageCount = 0 let duplicateCount = 0 @@ -382,6 +384,7 @@ async function streamImportSingle( onMeta: (meta: ParsedMeta) => { callbackStats.onMetaCalls++ + importedPlatform = meta.platform || importedPlatform if (!metaInserted) { logger?.info(`Writing meta: name=${meta.name}, type=${meta.type}, platform=${meta.platform}`) insertMeta.run( @@ -689,9 +692,9 @@ async function streamImportSingle( } if (importError) { - return { success: false, error: importError, diagnostics } + return { success: false, platform: importedPlatform, error: importError, diagnostics } } - return { success: true, sessionId, diagnostics } + return { success: true, sessionId, platform: importedPlatform, diagnostics } } // ==================== Dry-run analysis ==================== diff --git a/packages/node-runtime/src/services/analytics.test.ts b/packages/node-runtime/src/services/analytics.test.ts index bcef3d25..fb4450e2 100644 --- a/packages/node-runtime/src/services/analytics.test.ts +++ b/packages/node-runtime/src/services/analytics.test.ts @@ -3,55 +3,35 @@ import assert from 'node:assert/strict' import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { AnalyticsService } from './analytics' +import { AnalyticsService, type AnalyticsServiceOptions } from './analytics' -const APP_KEY = 'app-US-test' +const UMAMI_ENDPOINT = 'https://telemetry.example.com/api/send' +const UMAMI_WEBSITE_ID = 'website-test-id' function createTempSystemDir(): string { return mkdtempSync(join(tmpdir(), 'chatlab-analytics-')) } +function createOptions(overrides: Partial = {}): AnalyticsServiceOptions { + return { + appVersion: '1.2.3', + appType: 'desktop', + umami: { endpoint: UMAMI_ENDPOINT, websiteId: UMAMI_WEBSITE_ID }, + getAiModelConfigured: () => true, + ...overrides, + } +} + function readAnalyticsData(systemDir: string): { + anonymousId?: string | null firstReportDate?: string | null lastReportDate?: string | null + enabled?: boolean } { return JSON.parse(readFileSync(join(systemDir, 'analytics.json'), 'utf-8')) } -test('trackDailyActive does not mark the day as reported when fetch rejects', async () => { - const systemDir = createTempSystemDir() - const originalFetch = globalThis.fetch - const originalConsoleError = console.error - globalThis.fetch = (() => Promise.reject(new Error('offline'))) as typeof fetch - console.error = () => {} - - try { - await new AnalyticsService(systemDir, APP_KEY, '1.0.0').trackDailyActive() - - assert.equal(existsSync(join(systemDir, 'analytics.json')), false) - } finally { - console.error = originalConsoleError - globalThis.fetch = originalFetch - rmSync(systemDir, { recursive: true, force: true }) - } -}) - -test('trackDailyActive does not mark the day as reported when Aptabase returns non-OK', async () => { - const systemDir = createTempSystemDir() - const originalFetch = globalThis.fetch - globalThis.fetch = (() => Promise.resolve(new Response(null, { status: 500 }))) as typeof fetch - - try { - await new AnalyticsService(systemDir, APP_KEY, '1.0.0').trackDailyActive() - - assert.equal(existsSync(join(systemDir, 'analytics.json')), false) - } finally { - globalThis.fetch = originalFetch - rmSync(systemDir, { recursive: true, force: true }) - } -}) - -test('trackDailyActive marks the day as reported after a successful post', async () => { +test('track sends a normalized Umami event without private import details', async () => { const systemDir = createTempSystemDir() const originalFetch = globalThis.fetch const requests: Array<{ url: string; init?: RequestInit }> = [] @@ -61,14 +41,274 @@ test('trackDailyActive marks the day as reported after a successful post', async }) as typeof fetch try { - await new AnalyticsService(systemDir, APP_KEY, '1.0.0').trackDailyActive({ platform: 'cli-web' }) + const service = new AnalyticsService(systemDir, createOptions()) + service.setAppLocale('zh-CN') + + assert.equal( + await service.track('chat_import_completed', { + chat_platform: 'weixin', + duration_ms: 1234.4, + filename: 'private-chat.json', + }), + true + ) + + assert.equal(requests.length, 1) + assert.equal(requests[0].url, UMAMI_ENDPOINT) + const body = JSON.parse(String(requests[0].init?.body)) as { + type: string + payload: { + website: string + name: string + language: string + id: string + url: string + data: Record + } + } + assert.equal(body.type, 'event') + assert.equal(body.payload.website, UMAMI_WEBSITE_ID) + assert.equal(body.payload.name, 'chat_import_completed') + assert.equal(body.payload.language, 'zh-CN') + assert.match(body.payload.id, /^[0-9a-f-]{36}$/) + assert.equal(body.payload.url, '/import') + assert.deepEqual(body.payload.data, { + session_id: body.payload.data.session_id, + app_version: '1.2.3', + app_type: 'desktop', + os: process.platform === 'darwin' ? 'macos' : process.platform === 'win32' ? 'windows' : 'linux', + app_locale: 'zh-CN', + ai_model_configured: true, + schema_version: 1, + chat_platform: 'weixin', + duration_ms: 1234, + }) + assert.equal('filename' in body.payload.data, false) + assert.match(String(new Headers(requests[0].init?.headers).get('User-Agent')), /^ChatLab\/1\.2\.3/) + } finally { + globalThis.fetch = originalFetch + rmSync(systemDir, { recursive: true, force: true }) + } +}) + +test('track preserves custom platforms but rejects values that can contain private paths', async () => { + const systemDir = createTempSystemDir() + const originalFetch = globalThis.fetch + const requests: RequestInit[] = [] + globalThis.fetch = ((_input: RequestInfo | URL, init?: RequestInit) => { + requests.push(init ?? {}) + return Promise.resolve(new Response(null, { status: 204 })) + }) as typeof fetch + + try { + const service = new AnalyticsService(systemDir, createOptions()) + await service.track('chat_import_started', { chat_platform: '自定义导入器 v2' }) + await service.track('chat_import_started', { chat_platform: '/Users/alice/private-export' }) + + const platforms = requests.map((request) => JSON.parse(String(request.body)).payload.data.chat_platform) + assert.deepEqual(platforms, ['自定义导入器 v2', 'unknown']) + } finally { + globalThis.fetch = originalFetch + rmSync(systemDir, { recursive: true, force: true }) + } +}) + +test('track normalizes the shared locale instead of accepting arbitrary event data', async () => { + const systemDir = createTempSystemDir() + const requests: Array<{ url: string; init?: RequestInit }> = [] + const originalFetch = globalThis.fetch + globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => { + requests.push({ url: String(input), init }) + return Promise.resolve(new Response(null, { status: 204 })) + }) as typeof fetch + + try { + const service = new AnalyticsService(systemDir, createOptions()) + await service.track('feature_used', { feature_id: 'insights', app_locale: 'ja-JP' }) + await service.track('feature_used', { + feature_id: 'insights', + app_locale: '/Users/alice/private-chat.json', + }) + + const first = JSON.parse(String(requests[0].init?.body)) as { payload: { language: string } } + const second = JSON.parse(String(requests[1].init?.body)) as { + payload: { language: string; data: Record } + } + assert.equal(first.payload.language, 'ja-JP') + assert.equal(second.payload.language, 'unknown') + assert.equal(second.payload.data.app_locale, 'unknown') + assert.equal(JSON.stringify(second).includes('/Users/alice'), false) + } finally { + globalThis.fetch = originalFetch + rmSync(systemDir, { recursive: true, force: true }) + } +}) + +test('anonymous id persists across AnalyticsService instances', async () => { + const systemDir = createTempSystemDir() + const originalFetch = globalThis.fetch + const distinctIds: string[] = [] + globalThis.fetch = ((_input: RequestInfo | URL, init?: RequestInit) => { + distinctIds.push(JSON.parse(String(init?.body)).payload.id) + return Promise.resolve(new Response(null, { status: 204 })) + }) as typeof fetch + + try { + const options = createOptions() + await new AnalyticsService(systemDir, options).track('app_started') + await new AnalyticsService(systemDir, options).track('app_started') + + assert.equal(distinctIds[0], distinctIds[1]) + assert.match(distinctIds[0], /^[0-9a-f-]{36}$/) + assert.equal(readAnalyticsData(systemDir).anonymousId, distinctIds[0]) + } finally { + globalThis.fetch = originalFetch + rmSync(systemDir, { recursive: true, force: true }) + } +}) + +test('disabled analytics sends no event', async () => { + const systemDir = createTempSystemDir() + const originalFetch = globalThis.fetch + let requestCount = 0 + globalThis.fetch = (() => { + requestCount++ + return Promise.resolve(new Response(null, { status: 204 })) + }) as typeof fetch + + try { + const service = new AnalyticsService(systemDir, createOptions()) + service.setEnabled(false) + + assert.equal(await service.track('app_started'), false) + assert.equal(requestCount, 0) + } finally { + globalThis.fetch = originalFetch + rmSync(systemDir, { recursive: true, force: true }) + } +}) + +test('trackDailyActive remembers the locale while disabled for later opt-in events', async () => { + const systemDir = createTempSystemDir() + const originalFetch = globalThis.fetch + const requests: RequestInit[] = [] + globalThis.fetch = ((_input: RequestInfo | URL, init?: RequestInit) => { + requests.push(init ?? {}) + return Promise.resolve(new Response(null, { status: 204 })) + }) as typeof fetch + + try { + const service = new AnalyticsService(systemDir, createOptions()) + service.setEnabled(false) + + await service.trackDailyActive({ app_locale: 'ja-JP' }) + + assert.equal(requests.length, 0) + assert.equal(readAnalyticsData(systemDir).anonymousId, null) + + service.setEnabled(true) + assert.equal(await service.track('feature_used', { feature_id: 'insights' }), true) + + const body = JSON.parse(String(requests[0].body)) as { + payload: { language: string; data: Record } + } + assert.equal(body.payload.language, 'ja-JP') + assert.equal(body.payload.data.app_locale, 'ja-JP') + } finally { + globalThis.fetch = originalFetch + rmSync(systemDir, { recursive: true, force: true }) + } +}) + +test('unknown events and invalid feature ids are not sent', async () => { + const systemDir = createTempSystemDir() + const originalFetch = globalThis.fetch + let requestCount = 0 + globalThis.fetch = (() => { + requestCount++ + return Promise.resolve(new Response(null, { status: 204 })) + }) as typeof fetch + + try { + const service = new AnalyticsService(systemDir, createOptions()) + assert.equal(await service.track('arbitrary_event'), false) + assert.equal(await service.track('feature_used', { feature_id: 'some-user-controlled-value' }), false) + assert.equal(requestCount, 0) + } finally { + globalThis.fetch = originalFetch + rmSync(systemDir, { recursive: true, force: true }) + } +}) + +test('incremental import usage is tracked without platform properties', async () => { + const systemDir = createTempSystemDir() + const originalFetch = globalThis.fetch + const requests: RequestInit[] = [] + globalThis.fetch = ((_input: RequestInfo | URL, init?: RequestInit) => { + requests.push(init ?? {}) + return Promise.resolve(new Response(null, { status: 204 })) + }) as typeof fetch + + try { + const service = new AnalyticsService(systemDir, createOptions()) + assert.equal( + await service.track('incremental_import_used', { + chat_platform: 'private-platform-value', + }), + true + ) + + const body = JSON.parse(String(requests[0].body)) as { + payload: { name: string; url: string; data: Record } + } + assert.equal(body.payload.name, 'incremental_import_used') + assert.equal(body.payload.url, '/import/incremental') + assert.equal('chat_platform' in body.payload.data, false) + } finally { + globalThis.fetch = originalFetch + rmSync(systemDir, { recursive: true, force: true }) + } +}) + +test('trackDailyActive does not mark the day as reported when Umami rejects', async () => { + const systemDir = createTempSystemDir() + const originalFetch = globalThis.fetch + globalThis.fetch = (() => Promise.reject(new Error('offline'))) as typeof fetch + + try { + await new AnalyticsService(systemDir, createOptions()).trackDailyActive({ app_locale: 'en-US' }) + + const data = readAnalyticsData(systemDir) + assert.equal(data.lastReportDate, null) + assert.equal(data.firstReportDate, null) + assert.ok(data.anonymousId) + } finally { + globalThis.fetch = originalFetch + rmSync(systemDir, { recursive: true, force: true }) + } +}) + +test('trackDailyActive reports startup every process and daily activity once per day', async () => { + const systemDir = createTempSystemDir() + const originalFetch = globalThis.fetch + const eventNames: string[] = [] + globalThis.fetch = ((_input: RequestInfo | URL, init?: RequestInit) => { + eventNames.push(JSON.parse(String(init?.body)).payload.name) + return Promise.resolve(new Response(null, { status: 204 })) + }) as typeof fetch + + try { + const options = createOptions() + const firstProcess = new AnalyticsService(systemDir, options) + await firstProcess.trackDailyActive({ app_locale: 'zh-CN' }) + await firstProcess.trackDailyActive({ app_locale: 'zh-CN' }) + await new AnalyticsService(systemDir, options).trackDailyActive({ app_locale: 'zh-CN' }) const today = new Date().toISOString().slice(0, 10) - assert.equal(requests.length, 1) - assert.equal(requests[0].url, 'https://us.aptabase.com/api/v0/event') - assert.equal(JSON.parse(String(requests[0].init?.body)).eventName, 'app_active_new') + assert.deepEqual(eventNames, ['app_started', 'app_active_new', 'app_started']) assert.deepEqual(readAnalyticsData(systemDir), { enabled: true, + anonymousId: readAnalyticsData(systemDir).anonymousId, firstReportDate: today, lastReportDate: today, }) @@ -77,3 +317,52 @@ test('trackDailyActive marks the day as reported after a successful post', async rmSync(systemDir, { recursive: true, force: true }) } }) + +test('trackDailyActive deduplicates overlapping reports', async () => { + const systemDir = createTempSystemDir() + const originalFetch = globalThis.fetch + const eventNames: string[] = [] + let resolveStartup: (() => void) | undefined + globalThis.fetch = ((_input: RequestInfo | URL, init?: RequestInit) => { + eventNames.push(JSON.parse(String(init?.body)).payload.name) + if (eventNames.length === 1) { + return new Promise((resolve) => { + resolveStartup = () => resolve(new Response(null, { status: 204 })) + }) + } + return Promise.resolve(new Response(null, { status: 204 })) + }) as typeof fetch + + try { + const service = new AnalyticsService(systemDir, createOptions()) + const first = service.trackDailyActive({ app_locale: 'zh-CN' }) + const second = service.trackDailyActive({ app_locale: 'en-US' }) + resolveStartup?.() + await Promise.all([first, second]) + + assert.deepEqual(eventNames, ['app_started', 'app_active_new']) + } finally { + globalThis.fetch = originalFetch + rmSync(systemDir, { recursive: true, force: true }) + } +}) + +test('track is a no-op without the official build configuration', async () => { + const systemDir = createTempSystemDir() + const previousEndpoint = process.env.UMAMI_ENDPOINT + const previousWebsiteId = process.env.UMAMI_WEBSITE_ID + delete process.env.UMAMI_ENDPOINT + delete process.env.UMAMI_WEBSITE_ID + + try { + const service = new AnalyticsService(systemDir, { appVersion: '1.2.3', appType: 'desktop' }) + assert.equal(await service.track('app_started'), false) + assert.equal(existsSync(join(systemDir, 'analytics.json')), false) + } finally { + if (previousEndpoint === undefined) delete process.env.UMAMI_ENDPOINT + else process.env.UMAMI_ENDPOINT = previousEndpoint + if (previousWebsiteId === undefined) delete process.env.UMAMI_WEBSITE_ID + else process.env.UMAMI_WEBSITE_ID = previousWebsiteId + rmSync(systemDir, { recursive: true, force: true }) + } +}) diff --git a/packages/node-runtime/src/services/analytics.ts b/packages/node-runtime/src/services/analytics.ts index 00fc3488..fe7314f1 100644 --- a/packages/node-runtime/src/services/analytics.ts +++ b/packages/node-runtime/src/services/analytics.ts @@ -1,54 +1,282 @@ -import * as fs from 'fs' import * as path from 'path' +import { randomUUID } from 'node:crypto' +import type { AnalyticsAppType, AnalyticsEventName, AnalyticsPropertyValue } from '@openchatlab/shared-types' +import { readJsonFile, withFileLock, writeJsonFileAtomically } from '@openchatlab/config' +import { appLogger } from '../logging/app-logger' -const REGIONS: Record = { - US: 'https://us.aptabase.com', - EU: 'https://eu.aptabase.com', +export interface AnalyticsServiceOptions { + appVersion: string + appType: AnalyticsAppType + umami?: { + endpoint: string + websiteId: string + } + getAiModelConfigured?: () => boolean } interface AnalyticsData { lastReportDate: string | null firstReportDate: string | null + anonymousId: string | null enabled: boolean } -const DEFAULT_DATA: AnalyticsData = { lastReportDate: null, firstReportDate: null, enabled: true } +interface NormalizedEvent { + name: AnalyticsEventName + properties: Record +} + +interface AnalyticsEnvelope extends NormalizedEvent { + anonymousId: string + sessionId: string + appVersion: string + appType: AnalyticsAppType + os: 'macos' | 'windows' | 'linux' | 'other' + appLocale: string + aiModelConfigured: boolean +} + +const DEFAULT_DATA: AnalyticsData = { + lastReportDate: null, + firstReportDate: null, + anonymousId: null, + enabled: true, +} + +const EVENT_NAMES = new Set([ + 'app_started', + 'app_active_new', + 'app_active', + 'chat_import_started', + 'chat_import_completed', + 'chat_import_failed', + 'incremental_import_used', + 'feature_used', + 'ai_setup_completed', + 'ai_request_started', + 'ai_request_completed', +]) + +const FEATURE_IDS = new Set([ + 'insights', + 'ranking', + 'ai_chat', + 'sql_lab', + 'message_search', + 'member_management', + 'session_merge', + 'export', +]) + +const FAILURE_REASONS = new Set([ + 'auth', + 'network', + 'rate_limit', + 'model_not_found', + 'aborted', + 'parse', + 'write', + 'unknown', +]) + +const APP_LOCALES = new Set(['zh-CN', 'zh-TW', 'en-US', 'ja-JP']) + +function getUmamiConfigFromEnv(): AnalyticsServiceOptions['umami'] | undefined { + const endpoint = process.env.UMAMI_ENDPOINT?.trim() + const websiteId = process.env.UMAMI_WEBSITE_ID?.trim() + return endpoint && websiteId ? { endpoint, websiteId } : undefined +} + +function normalizeOs(): AnalyticsEnvelope['os'] { + if (process.platform === 'darwin') return 'macos' + if (process.platform === 'win32') return 'windows' + if (process.platform === 'linux') return 'linux' + return 'other' +} + +function normalizeLocale(value: unknown): string { + if (typeof value !== 'string') return 'unknown' + const trimmed = value.trim() + return APP_LOCALES.has(trimmed) ? trimmed : 'unknown' +} + +function normalizeAnalyticsImportPlatform(value: unknown): string { + if (typeof value !== 'string') return 'unknown' + const platform = value.trim() + // Keep custom import-format categories to measure unsupported formats. + // This is not a general privacy sanitizer; only paths and control characters are excluded. + const hasPathOrControlCharacter = [...platform].some( + (char) => char === '/' || char === '\\' || char.charCodeAt(0) < 32 || char.charCodeAt(0) === 127 + ) + return platform.length > 0 && platform.length <= 64 && !hasPathOrControlCharacter ? platform : 'unknown' +} + +function normalizeDuration(value: unknown): number | null { + if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) return null + return Math.min(Math.round(value), 24 * 60 * 60 * 1000) +} + +function normalizeEvent(eventName: string, props: Record = {}): NormalizedEvent | null { + if (!EVENT_NAMES.has(eventName as AnalyticsEventName)) return null + const name = eventName as AnalyticsEventName + const properties: Record = {} + + if (name.startsWith('chat_import_')) { + properties.chat_platform = normalizeAnalyticsImportPlatform(props.chat_platform) + } + + if (name === 'chat_import_completed' || name === 'ai_request_completed') { + const duration = normalizeDuration(props.duration_ms) + if (duration !== null) properties.duration_ms = duration + } + + if (name === 'feature_used') { + if (typeof props.feature_id !== 'string' || !FEATURE_IDS.has(props.feature_id)) return null + properties.feature_id = props.feature_id + } + + if (name === 'ai_request_completed') { + if (typeof props.success !== 'boolean') return null + properties.success = props.success + } + + if (name === 'chat_import_failed' || (name === 'ai_request_completed' && props.success === false)) { + const reason = typeof props.failure_reason === 'string' ? props.failure_reason : 'unknown' + properties.failure_reason = FAILURE_REASONS.has(reason) ? reason : 'unknown' + } + + return { name, properties } +} + +function eventPath(event: NormalizedEvent): string { + if (event.name === 'incremental_import_used') return '/import/incremental' + if (event.name.startsWith('chat_import_')) return '/import' + if (event.name.startsWith('ai_')) return '/ai' + if (event.name === 'feature_used') return `/features/${event.properties.feature_id}` + return '/app' +} export class AnalyticsService { private readonly dataPath: string - private readonly endpoint: string | null - private readonly appKey: string - private readonly appVersion: string - private readonly sessionId: string + private readonly options: AnalyticsServiceOptions + private readonly sessionId = randomUUID() + private appLocale = 'unknown' + private startupAttempted = false + // Product constraint: only one ChatLab runtime is expected to use a system directory at a time. + // Daily-active deduplication is intentionally process-local unless concurrent runtimes become supported. + private dailyActivePromise: Promise | null = null - constructor(systemDir: string, appKey: string, appVersion: string) { + constructor(systemDir: string, options: AnalyticsServiceOptions) { this.dataPath = path.join(systemDir, 'analytics.json') - this.appKey = appKey - this.appVersion = appVersion - this.sessionId = `${Math.floor(Date.now() / 1000)}${Math.floor(Math.random() * 1e8) - .toString() - .padStart(8, '0')}` - const parts = appKey.split('-') - const base = parts.length === 3 ? REGIONS[parts[1]] : undefined - this.endpoint = base ? `${base}/api/v0/event` : null + this.options = { + ...options, + umami: options.umami ?? getUmamiConfigFromEnv(), + } } private load(): AnalyticsData { try { - if (fs.existsSync(this.dataPath)) { - return { ...DEFAULT_DATA, ...JSON.parse(fs.readFileSync(this.dataPath, 'utf-8')) } - } - } catch (_e) { - // Corrupted or unreadable file — fall back to defaults + const stored = readJsonFile>(this.dataPath) + if (stored) return { ...DEFAULT_DATA, ...stored } + } catch (error) { + appLogger.error('analytics', 'Failed to read analytics settings; using defaults', error) } return { ...DEFAULT_DATA } } - private save(data: AnalyticsData): void { + private updateData(update: (data: AnalyticsData) => T, fallback: T): T { try { - fs.writeFileSync(this.dataPath, JSON.stringify(data, null, 2), 'utf-8') - } catch (e) { - console.error('[Analytics] Failed to save:', e) + return withFileLock(this.dataPath, () => { + const data = this.load() + const result = update(data) + writeJsonFileAtomically(this.dataPath, data, 0o600) + return result + }) + } catch (error) { + appLogger.error('analytics', 'Failed to update analytics settings', error) + return fallback + } + } + + private getOrCreateAnonymousId(): string { + const fallback = randomUUID() + return this.updateData((data) => { + data.anonymousId ??= randomUUID() + return data.anonymousId + }, fallback) + } + + private isAiModelConfigured(): boolean { + try { + return this.options.getAiModelConfigured?.() === true + } catch (error) { + appLogger.error('analytics', 'Failed to resolve AI configuration state', error) + return false + } + } + + private buildEnvelope(event: NormalizedEvent): AnalyticsEnvelope { + return { + ...event, + anonymousId: this.getOrCreateAnonymousId(), + sessionId: this.sessionId, + appVersion: this.options.appVersion, + appType: this.options.appType, + os: normalizeOs(), + appLocale: this.appLocale, + aiModelConfigured: this.isAiModelConfigured(), + } + } + + private commonProperties(envelope: AnalyticsEnvelope): Record { + return { + session_id: envelope.sessionId, + app_version: envelope.appVersion, + app_type: envelope.appType, + os: envelope.os, + app_locale: envelope.appLocale, + ai_model_configured: envelope.aiModelConfigured, + schema_version: 1, + } + } + + private async sendToUmami(envelope: AnalyticsEnvelope): Promise { + const umami = this.options.umami + if (!umami) return false + try { + const response = await fetch(umami.endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'User-Agent': `ChatLab/${envelope.appVersion} (${envelope.os}; ${envelope.appType})`, + }, + body: JSON.stringify({ + type: 'event', + payload: { + website: umami.websiteId, + hostname: 'chatlab', + url: eventPath(envelope), + language: envelope.appLocale, + name: envelope.name, + id: envelope.anonymousId, + data: { + ...this.commonProperties(envelope), + ...envelope.properties, + }, + }, + }), + signal: AbortSignal.timeout(5000), + }) + if (!response.ok) { + appLogger.warn('analytics', 'Umami rejected analytics event', { + eventName: envelope.name, + status: response.status, + }) + } + return response.ok + } catch (error) { + appLogger.error('analytics', 'Failed to send analytics event to Umami', error) + return false } } @@ -57,56 +285,65 @@ export class AnalyticsService { } setEnabled(enabled: boolean): void { - const data = this.load() - data.enabled = enabled - this.save(data) + this.updateData((data) => { + data.enabled = enabled + }, undefined) } - async track(eventName: string, props?: Record): Promise { - if (!this.endpoint) return false + setAppLocale(locale: string): void { + this.appLocale = normalizeLocale(locale) + } + + async track(eventName: string, props?: Record): Promise { if (!this.getEnabled()) return false - try { - const resp = await fetch(this.endpoint, { - method: 'POST', - headers: { 'Content-Type': 'application/json', 'App-Key': this.appKey }, - body: JSON.stringify({ - timestamp: new Date().toISOString(), - sessionId: this.sessionId, - eventName, - systemProps: { - isDebug: false, - locale: '', - osName: process.platform, - osVersion: '', - engineName: 'Node.js', - engineVersion: process.versions.node, - appVersion: this.appVersion, - sdkVersion: 'chatlab@1', - }, - props, - }), - }) - return resp.ok - } catch (e) { - console.error(`[Analytics] Failed to track ${eventName}:`, e) - return false - } + if (props?.app_locale !== undefined) this.setAppLocale(String(props.app_locale)) + const event = normalizeEvent(eventName, props) + if (!event) return false + if (!this.options.umami) return false + + const envelope = this.buildEnvelope(event) + const delivered = await this.sendToUmami(envelope) + appLogger.debug('analytics', delivered ? 'Analytics event delivered' : 'Analytics event was not delivered', { + eventName: envelope.name, + }) + return delivered } - async trackDailyActive(props?: Record): Promise { - if (!this.endpoint) return + trackDailyActive(props?: Record): Promise { + if (this.dailyActivePromise) { + const locale = props?.app_locale ?? props?.locale + if (locale !== undefined) this.setAppLocale(String(locale)) + return this.dailyActivePromise + } + + this.dailyActivePromise = this.trackDailyActiveInternal(props).finally(() => { + this.dailyActivePromise = null + }) + return this.dailyActivePromise + } + + private async trackDailyActiveInternal(props?: Record): Promise { + const locale = props?.app_locale ?? props?.locale + if (locale !== undefined) this.setAppLocale(String(locale)) + const data = this.load() if (!data.enabled) return + + if (!this.startupAttempted) { + this.startupAttempted = true + await this.track('app_started') + } + const today = new Date().toISOString().slice(0, 10) const isNew = data.firstReportDate === null - if (isNew) data.firstReportDate = today - if (data.lastReportDate === today) { - if (isNew) this.save(data) - return - } - const tracked = await this.track(isNew ? 'app_active_new' : 'app_active', props) + if (data.lastReportDate === today) return + + const tracked = await this.track(isNew ? 'app_active_new' : 'app_active') if (!tracked) return - data.lastReportDate = today - this.save(data) + + this.updateData((persisted) => { + persisted.firstReportDate ??= today + persisted.lastReportDate = today + }, undefined) } } diff --git a/packages/shared-types/index.ts b/packages/shared-types/index.ts index 017f8e9b..655998e2 100644 --- a/packages/shared-types/index.ts +++ b/packages/shared-types/index.ts @@ -673,3 +673,22 @@ export interface UiConfig { session_gap_threshold: number summary_strategy?: 'brief' | 'standard' } + +// ==================== 匿名使用统计 ==================== + +export type AnalyticsAppType = 'desktop' | 'cli' | 'cli_web' | 'web_wasm' + +export type AnalyticsEventName = + | 'app_started' + | 'app_active_new' + | 'app_active' + | 'chat_import_started' + | 'chat_import_completed' + | 'chat_import_failed' + | 'incremental_import_used' + | 'feature_used' + | 'ai_setup_completed' + | 'ai_request_started' + | 'ai_request_completed' + +export type AnalyticsPropertyValue = string | number | boolean diff --git a/src/composables/useSessionAnalysisPageBase.ts b/src/composables/useSessionAnalysisPageBase.ts index 3e351259..4ed8c058 100644 --- a/src/composables/useSessionAnalysisPageBase.ts +++ b/src/composables/useSessionAnalysisPageBase.ts @@ -8,6 +8,7 @@ import { formatLocalizedDate } from '@/utils' import { useTimeSelect } from './useTimeSelect' import { useDataService } from '@/services' import { abortAnalyticsRequests } from '@/services/utils/http' +import { trackProductEvent } from '@/services/product-analytics' interface UseSessionAnalysisPageBaseOptions { route: RouteLocationNormalizedLoaded @@ -45,6 +46,20 @@ export function useSessionAnalysisPageBase(options: UseSessionAnalysisPageBaseOp const activeTab = ref(resolveActiveTabFromRoute()) + watch( + activeTab, + (tab) => { + const featureId = { + insights: 'insights', + ranking: 'ranking', + 'ai-chat': 'ai_chat', + lab: 'sql_lab', + }[tab] + if (featureId) trackProductEvent('feature_used', { feature_id: featureId }) + }, + { immediate: true } + ) + const { timeRangeValue, fullTimeRange, availableYears, timeFilter, initialTimeState } = useTimeSelect(route, router, { activeTab, isInitialLoad, diff --git a/src/i18n/locales/en-US/settings.json b/src/i18n/locales/en-US/settings.json index 750d50db..3555b831 100644 --- a/src/i18n/locales/en-US/settings.json +++ b/src/i18n/locales/en-US/settings.json @@ -400,7 +400,7 @@ "privacy": { "title": "Privacy Settings", "analytics": "Anonymous Usage Statistics", - "analyticsDesc": "When enabled, the app collects non-sensitive data like version number and OS version to help improve the product" + "analyticsDesc": "When enabled, the app sends an anonymous installation ID, version, system, app type, language, approximate region, imported chat platform, and major feature usage. It never sends chat content, contacts, filenames, paths, model names, or keys" }, "developer": { "title": "Developer Options", diff --git a/src/i18n/locales/ja-JP/settings.json b/src/i18n/locales/ja-JP/settings.json index 53c97353..31b71dd6 100644 --- a/src/i18n/locales/ja-JP/settings.json +++ b/src/i18n/locales/ja-JP/settings.json @@ -400,7 +400,7 @@ "privacy": { "title": "プライバシー設定", "analytics": "匿名利用統計", - "analyticsDesc": "有効にすると、バージョン番号やOS情報などの非機密データを収集し、製品の改善に役立てます" + "analyticsDesc": "有効にすると、匿名のインストールID、バージョン、OS、アプリ種別、言語、おおよその地域、インポート元のチャットサービス、主要機能の利用状況を送信します。チャット内容、連絡先、ファイル名、パス、モデル名、キーは送信しません" }, "developer": { "title": "開発者オプション", diff --git a/src/i18n/locales/zh-CN/settings.json b/src/i18n/locales/zh-CN/settings.json index 793fc1a7..1b458edf 100644 --- a/src/i18n/locales/zh-CN/settings.json +++ b/src/i18n/locales/zh-CN/settings.json @@ -400,7 +400,7 @@ "privacy": { "title": "隐私设置", "analytics": "匿名使用统计", - "analyticsDesc": "开启后,软件会收集版本号、操作系统版本等非敏感数据,用于帮助优化产品" + "analyticsDesc": "开启后,软件会发送匿名安装标识、版本、系统、运行方式、语言、粗略地区,以及导入平台和主要功能使用情况;不会发送聊天内容、联系人、文件名、路径、模型名称或密钥" }, "developer": { "title": "开发者选项", diff --git a/src/i18n/locales/zh-TW/settings.json b/src/i18n/locales/zh-TW/settings.json index a32b23da..d7e31957 100644 --- a/src/i18n/locales/zh-TW/settings.json +++ b/src/i18n/locales/zh-TW/settings.json @@ -400,7 +400,7 @@ "privacy": { "title": "隱私設定", "analytics": "匿名使用統計", - "analyticsDesc": "開啟後,軟體會收集版本號、作業系統版本等非敏感資料,用於幫助最佳化產品" + "analyticsDesc": "開啟後,軟體會傳送匿名安裝識別碼、版本、系統、執行方式、語言、概略地區,以及匯入平台和主要功能使用情況;不會傳送聊天內容、聯絡人、檔名、路徑、模型名稱或金鑰" }, "developer": { "title": "開發者選項", diff --git a/src/services/ai-stream/service.ts b/src/services/ai-stream/service.ts index 96aa3057..0127bd3a 100644 --- a/src/services/ai-stream/service.ts +++ b/src/services/ai-stream/service.ts @@ -1,5 +1,6 @@ import type { LlmStreamChunk, AgentStreamChunk, AgentStreamResult, AgentStreamParams } from './types' import { fetchSSE } from '../utils/sse' +import { trackProductEvent } from '../product-analytics' /** * LLM stream service — uses fetchSSE to consume the shared @@ -52,6 +53,8 @@ export function useAgentStreamService() { params: AgentStreamParams, onChunk?: (chunk: AgentStreamChunk) => void ): { requestId: string; promise: Promise } { + const startedAt = Date.now() + trackProductEvent('ai_request_started') const abortController = new AbortController() const localRequestId = `local_${Date.now()}_${Math.random().toString(36).slice(2, 8)}` requestIdMap.set(localRequestId, { serverId: '', abortController }) @@ -133,7 +136,24 @@ export function useAgentStreamService() { }) }) - return { requestId: localRequestId, promise } + const trackedPromise = promise.then((result) => { + trackProductEvent('ai_request_completed', { + success: result.success, + duration_ms: Date.now() - startedAt, + ...(result.success + ? {} + : { + failure_reason: result.result?.aborted + ? 'aborted' + : result.error?.name === 'FetchError' + ? 'network' + : 'unknown', + }), + }) + return result + }) + + return { requestId: localRequestId, promise: trackedPromise } }, async abort(requestId: string): Promise<{ success: boolean; error?: string }> { diff --git a/src/services/import/electron.ts b/src/services/import/electron.ts index 9b05c42b..d87c9629 100644 --- a/src/services/import/electron.ts +++ b/src/services/import/electron.ts @@ -57,6 +57,7 @@ export class ElectronImportAdapter implements ImportAdapter { normalizeImportResult({ success: result.success, sessionId: result.sessionId, + platform: result.platform, error: result.error, importMode: result.importMode, matchedBy: result.matchedBy, @@ -196,6 +197,7 @@ export class ElectronImportAdapter implements ImportAdapter { normalizeImportResult({ success: result.success, sessionId: result.sessionId, + platform: result.platform, error: result.error, importMode: result.importMode, matchedBy: result.matchedBy, diff --git a/src/services/import/importSourceAdapters.test.ts b/src/services/import/importSourceAdapters.test.ts index 98f792be..74fc083e 100644 --- a/src/services/import/importSourceAdapters.test.ts +++ b/src/services/import/importSourceAdapters.test.ts @@ -123,4 +123,28 @@ describe('archive import source adapters', () => { timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone, }) }) + + it('preserves failed directory-import platform metadata from SSE', async () => { + globalThis.fetch = (async () => + new Response('event: error\ndata: {"success":false,"platform":"line","error":"database unavailable"}\n\n', { + headers: { 'Content-Type': 'text/event-stream' }, + })) as typeof fetch + + const result = await new FetchImportAdapter().importDirectory([new File(['{}'], 'chat.json')]) + + assert.deepEqual(result, { + success: false, + sessionId: undefined, + platform: 'line', + error: 'database unavailable', + importMode: undefined, + matchedBy: undefined, + createReason: undefined, + newMessageCount: undefined, + duplicateCount: undefined, + messageCount: undefined, + memberCount: undefined, + diagnostics: undefined, + }) + }) }) diff --git a/src/services/import/telemetry.test.ts b/src/services/import/telemetry.test.ts new file mode 100644 index 00000000..70316d71 --- /dev/null +++ b/src/services/import/telemetry.test.ts @@ -0,0 +1,198 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import type { AnalyticsEventName } from '@openchatlab/shared-types' +import type { ImportAdapter, ImportResult } from './types' +import { TelemetryImportAdapter } from './telemetry' + +function createDelegate( + importResult: ImportResult = { success: true, sessionId: 'session-1', platform: 'weixin' } +): ImportAdapter { + return { + importFile: async () => importResult, + detectFormat: async () => ({ + id: 'chatlab', + name: 'ChatLab JSON', + platform: 'qq', + extensions: ['json'], + }), + scanMultiChatFile: async () => [], + prepareImportSource: async () => ({ success: false }), + importPreparedChat: async () => importResult, + releaseImportSource: async () => {}, + getSupportedFormats: async () => [], + importDemo: async () => ({ success: true }), + analyzeIncrementalImport: async () => ({ newMessageCount: 0, duplicateCount: 0, totalInFile: 0, platform: 'qq' }), + incrementalImport: async () => ({ success: true, newMessageCount: 0 }), + importDirectory: async () => importResult, + } +} + +describe('TelemetryImportAdapter', () => { + it('tracks the remembered format and the actual imported platform without file details', async () => { + const events: Array<{ name: AnalyticsEventName; properties?: Record }> = [] + const file = new File(['{}'], 'private-chat.json') + const adapter = new TelemetryImportAdapter(createDelegate(), { + trackAnalyticsEvent: async (name, properties) => { + events.push({ name, properties }) + }, + }) + + await adapter.detectFormat(file) + await adapter.importFile(file) + await new Promise((resolve) => setTimeout(resolve, 0)) + + assert.equal(events.length, 2) + assert.deepEqual(events[0], { + name: 'chat_import_started', + properties: { chat_platform: 'qq' }, + }) + assert.equal(events[1].name, 'chat_import_completed') + assert.equal(events[1].properties?.chat_platform, 'weixin') + assert.equal(typeof events[1].properties?.duration_ms, 'number') + assert.equal(JSON.stringify(events).includes('private-chat.json'), false) + }) + + it('only sends a whitelisted failure category', async () => { + const events: Array<{ name: AnalyticsEventName; properties?: Record }> = [] + const adapter = new TelemetryImportAdapter( + createDelegate({ success: false, error: 'Parser failed at /Users/alice/private.json' }), + { + trackAnalyticsEvent: async (name, properties) => { + events.push({ name, properties }) + }, + } + ) + + await adapter.importFile(new File(['{}'], 'private.json'), { formatId: 'whatsapp-native-txt' }) + await new Promise((resolve) => setTimeout(resolve, 0)) + + assert.deepEqual(events.at(-1), { + name: 'chat_import_failed', + properties: { chat_platform: 'whatsapp', failure_reason: 'parse' }, + }) + assert.equal(JSON.stringify(events).includes('/Users/alice'), false) + }) + + it('classifies a rejected import request as a network failure', async () => { + const events: Array<{ name: AnalyticsEventName; properties?: Record }> = [] + const delegate = createDelegate() + delegate.importFile = async () => { + throw new TypeError('Failed to fetch') + } + const adapter = new TelemetryImportAdapter(delegate, { + trackAnalyticsEvent: async (name, properties) => { + events.push({ name, properties }) + }, + }) + + await assert.rejects(() => adapter.importFile(new File(['{}'], 'private.json')), /Failed to fetch/) + await new Promise((resolve) => setTimeout(resolve, 0)) + + assert.deepEqual(events.at(-1), { + name: 'chat_import_failed', + properties: { chat_platform: 'unknown', failure_reason: 'network' }, + }) + }) + + it('tracks the detected platform returned by a failed created import', async () => { + const events: Array<{ name: AnalyticsEventName; properties?: Record }> = [] + const adapter = new TelemetryImportAdapter( + createDelegate({ success: false, platform: 'line', error: 'database unavailable' }), + { + trackAnalyticsEvent: async (name, properties) => { + events.push({ name, properties }) + }, + } + ) + + await adapter.importFile(new File(['{}'], 'private.json')) + await new Promise((resolve) => setTimeout(resolve, 0)) + + assert.deepEqual(events.at(-1), { + name: 'chat_import_failed', + properties: { chat_platform: 'line', failure_reason: 'write' }, + }) + }) + + it('tracks the detected platform returned by a failed directory import', async () => { + const events: Array<{ name: AnalyticsEventName; properties?: Record }> = [] + const adapter = new TelemetryImportAdapter( + createDelegate({ success: false, platform: 'line', error: 'database unavailable' }), + { + trackAnalyticsEvent: async (name, properties) => { + events.push({ name, properties }) + }, + } + ) + + await adapter.importDirectory([new File(['{}'], 'chat.json')]) + await new Promise((resolve) => setTimeout(resolve, 0)) + + assert.deepEqual(events.at(-1), { + name: 'chat_import_failed', + properties: { chat_platform: 'line', failure_reason: 'write' }, + }) + }) + + it('tracks incremental imports without exposing the session or file name', async () => { + const events: Array<{ name: AnalyticsEventName; properties?: Record }> = [] + const file = new File(['{}'], 'incremental-private.json') + const adapter = new TelemetryImportAdapter(createDelegate(), { + trackAnalyticsEvent: async (name, properties) => { + events.push({ name, properties }) + }, + }) + + await adapter.analyzeIncrementalImport('private-session-id', file) + await adapter.incrementalImport('private-session-id', file) + await new Promise((resolve) => setTimeout(resolve, 0)) + + assert.deepEqual(events, [{ name: 'incremental_import_used', properties: undefined }]) + assert.equal(JSON.stringify(events).includes('private-session-id'), false) + assert.equal(JSON.stringify(events).includes('incremental-private.json'), false) + }) + + it('tracks automatic incremental imports as a platform-independent usage event', async () => { + const events: Array<{ name: AnalyticsEventName; properties?: Record }> = [] + const adapter = new TelemetryImportAdapter( + createDelegate({ + success: true, + sessionId: 'existing-session', + importMode: 'incremental', + platform: 'private-platform-value', + }), + { + trackAnalyticsEvent: async (name, properties) => { + events.push({ name, properties }) + }, + } + ) + + await adapter.importFile(new File(['{}'], 'incremental.json')) + await new Promise((resolve) => setTimeout(resolve, 0)) + + assert.deepEqual(events, [{ name: 'incremental_import_used', properties: undefined }]) + }) + + it('counts failed automatic incremental attempts without a platform failure event', async () => { + const events: Array<{ name: AnalyticsEventName; properties?: Record }> = [] + const adapter = new TelemetryImportAdapter( + createDelegate({ + success: false, + sessionId: 'existing-session', + importMode: 'incremental', + error: 'write failed', + }), + { + trackAnalyticsEvent: async (name, properties) => { + events.push({ name, properties }) + }, + } + ) + + await adapter.importFile(new File(['{}'], 'incremental.json')) + await new Promise((resolve) => setTimeout(resolve, 0)) + + assert.deepEqual(events, [{ name: 'incremental_import_used', properties: undefined }]) + }) +}) diff --git a/src/services/import/telemetry.ts b/src/services/import/telemetry.ts new file mode 100644 index 00000000..5e0df858 --- /dev/null +++ b/src/services/import/telemetry.ts @@ -0,0 +1,201 @@ +import type { AnalyticsEventName } from '@openchatlab/shared-types' +import type { PlatformAdapter } from '../platform/types' +import type { + DemoImportResult, + DemoProgress, + FormatInfo, + ImportAdapter, + ImportOptions, + ImportProgress, + ImportResult, + IncrementalAnalysis, + IncrementalImportResult, + MultiChatEntry, + PreparedImportSourceResult, +} from './types' + +function platformFromFormatId(formatId: string | undefined): string { + if (!formatId) return 'unknown' + if (formatId.includes('qq')) return 'qq' + if (formatId.includes('weflow') || formatId.includes('wechat') || formatId.includes('echotrace')) return 'wechat' + if (formatId.includes('whatsapp')) return 'whatsapp' + if (formatId.includes('line')) return 'line' + if (formatId.includes('telegram')) return 'telegram' + if (formatId.includes('discord')) return 'discord' + if (formatId.includes('instagram')) return 'instagram' + if (formatId.includes('google-chat')) return 'google_chat' + return 'unknown' +} + +function classifyImportFailure(error: string | undefined): string { + const normalized = error?.toLowerCase() ?? '' + if ( + normalized.includes('failed to fetch') || + normalized.includes('fetch failed') || + normalized.includes('networkerror') || + normalized.includes('network error') || + normalized.includes('network request failed') || + normalized.includes('load failed') + ) { + return 'network' + } + if ( + normalized.includes('parse') || + normalized.includes('format') || + normalized.includes('no_messages') || + normalized.includes('unrecognized') + ) { + return 'parse' + } + if (normalized.includes('write') || normalized.includes('database') || normalized.includes('sqlite')) { + return 'write' + } + return 'unknown' +} + +export class TelemetryImportAdapter implements ImportAdapter { + private readonly filePlatforms = new WeakMap() + private readonly pathPlatforms = new Map() + private readonly sourcePlatforms = new Map() + + constructor( + private readonly delegate: ImportAdapter, + private readonly platform: Pick + ) {} + + private track(eventName: AnalyticsEventName, properties?: Record): void { + void this.platform.trackAnalyticsEvent(eventName, properties).catch(() => {}) + } + + private rememberedPlatform(source: File | string, options?: ImportOptions): string { + const selected = platformFromFormatId(options?.formatId) + if (selected !== 'unknown') return selected + return typeof source === 'string' + ? (this.pathPlatforms.get(source) ?? 'unknown') + : (this.filePlatforms.get(source) ?? 'unknown') + } + + private rememberPlatform(source: File | string, platform: string): void { + if (typeof source === 'string') this.pathPlatforms.set(source, platform) + else this.filePlatforms.set(source, platform) + } + + private async runTrackedImport< + T extends { success: boolean; error?: string; platform?: string; importMode?: 'created' | 'incremental' }, + >(initialPlatform: string, operation: () => Promise): Promise { + const startedAt = Date.now() + const trackStarted = () => this.track('chat_import_started', { chat_platform: initialPlatform }) + try { + const result = await operation() + if (result.importMode === 'incremental') { + this.track('incremental_import_used') + return result + } + trackStarted() + const chatPlatform = result.platform ?? initialPlatform + if (result.success) { + this.track('chat_import_completed', { + chat_platform: chatPlatform, + duration_ms: Date.now() - startedAt, + }) + } else { + this.track('chat_import_failed', { + chat_platform: chatPlatform, + failure_reason: classifyImportFailure(result.error), + }) + } + return result + } catch (error) { + trackStarted() + this.track('chat_import_failed', { + chat_platform: initialPlatform, + failure_reason: classifyImportFailure(error instanceof Error ? error.message : undefined), + }) + throw error + } + } + + importFile( + file: File | string, + options?: ImportOptions, + onProgress?: (progress: ImportProgress) => void + ): Promise { + return this.runTrackedImport(this.rememberedPlatform(file, options), () => + this.delegate.importFile(file, options, onProgress) + ) + } + + cancelActiveImport(): void { + this.delegate.cancelActiveImport?.() + } + + async detectFormat(file: File | string): Promise { + const result = await this.delegate.detectFormat(file) + if (result) { + this.rememberPlatform(file, result.platform) + } + return result + } + + scanMultiChatFile(file: File | string): Promise { + return this.delegate.scanMultiChatFile(file) + } + + async prepareImportSource(file: File | string): Promise { + const result = await this.delegate.prepareImportSource(file) + if (result.success && result.source) { + this.sourcePlatforms.set(result.source.sourceId, result.source.platform) + } + return result + } + + importPreparedChat( + sourceId: string, + chatId: string, + onProgress?: (progress: ImportProgress) => void + ): Promise { + return this.runTrackedImport(this.sourcePlatforms.get(sourceId) ?? 'unknown', () => + this.delegate.importPreparedChat(sourceId, chatId, onProgress) + ) + } + + async releaseImportSource(sourceId: string): Promise { + try { + await this.delegate.releaseImportSource(sourceId) + } finally { + this.sourcePlatforms.delete(sourceId) + } + } + + getSupportedFormats(): Promise { + return this.delegate.getSupportedFormats() + } + + importDemo(locale: string, onProgress?: (progress: DemoProgress) => void): Promise { + return this.delegate.importDemo(locale, onProgress) + } + + async analyzeIncrementalImport(sessionId: string, file: File | string): Promise { + const result = await this.delegate.analyzeIncrementalImport(sessionId, file) + if (result.platform) this.rememberPlatform(file, result.platform) + return result + } + + incrementalImport( + sessionId: string, + file: File | string, + onProgress?: (progress: ImportProgress) => void + ): Promise { + this.track('incremental_import_used') + return this.delegate.incrementalImport(sessionId, file, onProgress) + } + + importDirectory( + source: File[] | string, + options?: ImportOptions, + onProgress?: (progress: ImportProgress) => void + ): Promise { + const platform = platformFromFormatId(options?.formatId) + return this.runTrackedImport(platform, () => this.delegate.importDirectory(source, options, onProgress)) + } +} diff --git a/src/services/import/types.ts b/src/services/import/types.ts index 237fe5a6..194cd881 100644 --- a/src/services/import/types.ts +++ b/src/services/import/types.ts @@ -23,6 +23,7 @@ export type AutoImportCreateReason = 'no-match' | 'ambiguous' export interface ImportResult { success: boolean sessionId?: string + platform?: string error?: string importMode?: AutoImportMode matchedBy?: AutoImportMatchMethod @@ -38,6 +39,7 @@ export function normalizeImportResult(result: ImportResult): ImportResult { return { success: result.success, sessionId: result.sessionId, + platform: result.platform, error: result.error, importMode: result.importMode, matchedBy: result.matchedBy, @@ -124,6 +126,7 @@ export interface IncrementalAnalysis { newMessageCount: number duplicateCount: number totalInFile: number + platform?: string error?: string } diff --git a/src/services/platform/browser.ts b/src/services/platform/browser.ts index da9cb814..2de90d53 100644 --- a/src/services/platform/browser.ts +++ b/src/services/platform/browser.ts @@ -6,6 +6,7 @@ import type { PlatformAdapter, RemoteConfigResult, } from './types' +import type { AnalyticsEventName } from '@openchatlab/shared-types' declare const __APP_VERSION__: string @@ -52,6 +53,11 @@ export class BrowserPlatformAdapter implements PlatformAdapter { void locale } + async trackAnalyticsEvent(eventName: AnalyticsEventName, properties?: Record): Promise { + void eventName + void properties + } + async showOpenDialog(_options: OpenDialogOptions): Promise { return { canceled: true, filePaths: [] } } diff --git a/src/services/platform/cli-web.test.ts b/src/services/platform/cli-web.test.ts index 748ba04d..a93cf4b0 100644 --- a/src/services/platform/cli-web.test.ts +++ b/src/services/platform/cli-web.test.ts @@ -81,6 +81,28 @@ describe('CliWebPlatformAdapter', () => { } }) + it('forwards typed analytics events to the CLI web backend', async () => { + const originalFetch = globalThis.fetch + const requests: Array<{ url: string; init?: RequestInit }> = [] + globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => { + requests.push({ url: String(input), init }) + return Promise.resolve(new Response(JSON.stringify({ ok: true }))) + }) as typeof fetch + + try { + await new CliWebPlatformAdapter().trackAnalyticsEvent('feature_used', { feature_id: 'insights' }) + + assert.equal(requests[0].url, '/_web/telemetry/track') + assert.equal(requests[0].init?.method, 'POST') + assert.equal( + requests[0].init?.body, + JSON.stringify({ eventName: 'feature_used', properties: { feature_id: 'insights' } }) + ) + } finally { + globalThis.fetch = originalFetch + } + }) + it('writes a PNG data URL to the browser image clipboard', async () => { const originalFetch = globalThis.fetch let clipboardItems: unknown[] = [] diff --git a/src/services/platform/cli-web.ts b/src/services/platform/cli-web.ts index b01d376a..1f9d67d5 100644 --- a/src/services/platform/cli-web.ts +++ b/src/services/platform/cli-web.ts @@ -12,6 +12,7 @@ import type { CheckUpdateResult, PerformUpdateResult, } from './types' +import type { AnalyticsEventName } from '@openchatlab/shared-types' import { fetchWithAuth } from '../utils/http' import { reportError } from '../log-report' @@ -74,7 +75,15 @@ export class CliWebPlatformAdapter implements PlatformAdapter { await fetchWithAuth('/_web/telemetry/track', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ eventName: 'app_active', properties: { platform: 'cli-web', locale } }), + body: JSON.stringify({ eventName: 'app_active', properties: { app_locale: locale } }), + }).catch(() => {}) + } + + async trackAnalyticsEvent(eventName: AnalyticsEventName, properties?: Record): Promise { + await fetchWithAuth('/_web/telemetry/track', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ eventName, properties }), }).catch(() => {}) } diff --git a/src/services/platform/electron.ts b/src/services/platform/electron.ts index 9434233d..e1afffc4 100644 --- a/src/services/platform/electron.ts +++ b/src/services/platform/electron.ts @@ -2,6 +2,7 @@ * ElectronPlatformAdapter — wrap window.api */ +import type { AnalyticsEventName } from '@openchatlab/shared-types' import type { PlatformAdapter, OpenDialogOptions, OpenDialogResult, RemoteConfigResult } from './types' export class ElectronPlatformAdapter implements PlatformAdapter { @@ -37,6 +38,10 @@ export class ElectronPlatformAdapter implements PlatformAdapter { return window.api.app.trackDailyActive(locale) } + trackAnalyticsEvent(eventName: AnalyticsEventName, properties?: Record): Promise { + return window.api.app.trackAnalyticsEvent(eventName, properties) + } + showOpenDialog(options: OpenDialogOptions): Promise { return window.api.dialog.showOpenDialog(options as Electron.OpenDialogOptions) } diff --git a/src/services/platform/types.ts b/src/services/platform/types.ts index cbf302ca..09d3df2c 100644 --- a/src/services/platform/types.ts +++ b/src/services/platform/types.ts @@ -1,3 +1,5 @@ +import type { AnalyticsEventName } from '@openchatlab/shared-types' + /** * PlatformAdapter — 平台能力领域适配器接口 * @@ -48,6 +50,7 @@ export interface PlatformAdapter { getAnalyticsEnabled(): Promise setAnalyticsEnabled(enabled: boolean): Promise<{ success: boolean }> trackDailyActive(locale: string): Promise + trackAnalyticsEvent(eventName: AnalyticsEventName, properties?: Record): Promise showOpenDialog(options: OpenDialogOptions): Promise copyImageToClipboard(dataUrl: string): Promise<{ success: boolean; error?: string }> diff --git a/src/services/product-analytics.test.ts b/src/services/product-analytics.test.ts new file mode 100644 index 00000000..02cc1035 --- /dev/null +++ b/src/services/product-analytics.test.ts @@ -0,0 +1,34 @@ +import assert from 'node:assert/strict' +import { setImmediate as waitForImmediate } from 'node:timers/promises' +import test from 'node:test' +import type { AnalyticsEventName } from '@openchatlab/shared-types' + +test('includes the current locale in an event before daily-active initialization', async (t) => { + const events: Array<{ name: AnalyticsEventName; properties?: Record }> = [] + + await t.mock.module('@/i18n', { + namedExports: { + getLocale: () => 'zh-TW', + }, + }) + await t.mock.module('./platform/service', { + namedExports: { + usePlatformService: () => ({ + trackAnalyticsEvent: async (name: AnalyticsEventName, properties?: Record) => { + events.push({ name, properties }) + }, + }), + }, + }) + + const { trackProductEvent } = await import('./product-analytics') + trackProductEvent('feature_used', { feature_id: 'insights' }) + await waitForImmediate() + + assert.deepEqual(events, [ + { + name: 'feature_used', + properties: { feature_id: 'insights', app_locale: 'zh-TW' }, + }, + ]) +}) diff --git a/src/services/product-analytics.ts b/src/services/product-analytics.ts new file mode 100644 index 00000000..68ee027c --- /dev/null +++ b/src/services/product-analytics.ts @@ -0,0 +1,13 @@ +import type { AnalyticsEventName } from '@openchatlab/shared-types' +import { getLocale } from '@/i18n' +import { usePlatformService } from './platform/service' + +export function trackProductEvent(eventName: AnalyticsEventName, properties?: Record): void { + try { + void usePlatformService() + .trackAnalyticsEvent(eventName, { ...properties, app_locale: getLocale() }) + .catch(() => {}) + } catch { + // Analytics is best-effort and may run before the platform adapter is registered in tests or early startup. + } +} diff --git a/src/services/registry.ts b/src/services/registry.ts index 4f3931ba..40577616 100644 --- a/src/services/registry.ts +++ b/src/services/registry.ts @@ -84,8 +84,15 @@ async function initElectronAdapters(): Promise { const { FetchDataAdapter } = await import('./data/fetch') registerAdapter('data', new FetchDataAdapter()) - const { ElectronImportAdapter } = await import('./import/electron') - registerAdapter('import', new ElectronImportAdapter()) + const { ElectronPlatformAdapter } = await import('./platform/electron') + const platformAdapter = new ElectronPlatformAdapter() + registerAdapter('platform', platformAdapter) + + const [{ ElectronImportAdapter }, { TelemetryImportAdapter }] = await Promise.all([ + import('./import/electron'), + import('./import/telemetry'), + ]) + registerAdapter('import', new TelemetryImportAdapter(new ElectronImportAdapter(), platformAdapter)) const { FetchSessionIndexAdapter } = await import('./session-index/fetch') registerAdapter('session-index', new FetchSessionIndexAdapter()) @@ -93,9 +100,6 @@ async function initElectronAdapters(): Promise { const { FetchMessageAdapter } = await import('./message/fetch') registerAdapter('message', new FetchMessageAdapter()) - const { ElectronPlatformAdapter } = await import('./platform/electron') - registerAdapter('platform', new ElectronPlatformAdapter()) - const { ElectronAIAdapter } = await import('./ai/electron') registerAdapter('ai', new ElectronAIAdapter()) @@ -121,8 +125,15 @@ async function initCliWebAdapters(): Promise { const { FetchDataAdapter } = await import('./data/fetch') registerAdapter('data', new FetchDataAdapter()) - const { FetchImportAdapter } = await import('./import/fetch') - registerAdapter('import', new FetchImportAdapter()) + const { CliWebPlatformAdapter } = await import('./platform/cli-web') + const platformAdapter = new CliWebPlatformAdapter() + registerAdapter('platform', platformAdapter) + + const [{ FetchImportAdapter }, { TelemetryImportAdapter }] = await Promise.all([ + import('./import/fetch'), + import('./import/telemetry'), + ]) + registerAdapter('import', new TelemetryImportAdapter(new FetchImportAdapter(), platformAdapter)) const { FetchSessionIndexAdapter } = await import('./session-index/fetch') registerAdapter('session-index', new FetchSessionIndexAdapter()) @@ -130,9 +141,6 @@ async function initCliWebAdapters(): Promise { const { FetchMessageAdapter } = await import('./message/fetch') registerAdapter('message', new FetchMessageAdapter()) - const { CliWebPlatformAdapter } = await import('./platform/cli-web') - registerAdapter('platform', new CliWebPlatformAdapter()) - const { FetchAIAdapter } = await import('./ai/fetch') registerAdapter('ai', new FetchAIAdapter()) diff --git a/src/stores/llm.ts b/src/stores/llm.ts index 79a60c70..8c3dc85c 100644 --- a/src/stores/llm.ts +++ b/src/stores/llm.ts @@ -3,6 +3,7 @@ import { ref, computed } from 'vue' import type { ProviderDefinition, ModelDefinition } from '@electron/preload/index' import { useLLMService } from '@/services' import type { AIServiceConfigDisplay, LLMProvider } from '@/services' +import { trackProductEvent } from '@/services/product-analytics' export type { AIServiceConfigDisplay, LLMProvider } @@ -27,6 +28,7 @@ export const useLLMStore = defineStore('llm', () => { ) const fastModelConfig = computed(() => configs.value.find((c) => c.id === fastModel.value?.configId) || null) const hasConfig = computed(() => !!defaultAssistant.value) + const hasConfiguredModel = computed(() => !!defaultAssistantConfig.value && !!defaultAssistant.value?.modelId.trim()) const isMaxConfigs = computed(() => configs.value.length >= 99) function getProviderById(id: string): ProviderDefinition | undefined { @@ -54,6 +56,8 @@ export const useLLMStore = defineStore('llm', () => { } async function loadConfigs() { + const wasInitialized = isInitialized.value + const wasConfigured = hasConfiguredModel.value isLoading.value = true try { const svc = useLLMService() @@ -71,6 +75,9 @@ export const useLLMStore = defineStore('llm', () => { configs.value = configStore.configs defaultAssistant.value = assistantSlot fastModel.value = fastSlot + if (wasInitialized && !wasConfigured && hasConfiguredModel.value) { + trackProductEvent('ai_setup_completed') + } } catch (error) { console.error('[LLM Store] 加载配置失败:', error) } finally { @@ -79,10 +86,14 @@ export const useLLMStore = defineStore('llm', () => { } async function setDefaultAssistantModel(configId: string, modelId: string): Promise { + const wasConfigured = hasConfiguredModel.value try { const result = await useLLMService().setDefaultAssistantModel(configId, modelId) if (result.success) { defaultAssistant.value = { configId, modelId } + if (!wasConfigured && hasConfiguredModel.value) { + trackProductEvent('ai_setup_completed') + } return true } console.error('[LLM Store] 设置默认助手模型失败:', result.error) @@ -132,6 +143,7 @@ export const useLLMStore = defineStore('llm', () => { defaultAssistantConfig, fastModelConfig, hasConfig, + hasConfiguredModel, isMaxConfigs, // 方法 init, diff --git a/src/stores/settings.test.ts b/src/stores/settings.test.ts new file mode 100644 index 00000000..da41528e --- /dev/null +++ b/src/stores/settings.test.ts @@ -0,0 +1,64 @@ +import assert from 'node:assert/strict' +import { setImmediate as waitForImmediate } from 'node:timers/promises' +import test from 'node:test' +import { createPinia, setActivePinia } from 'pinia' +import { registerAdapter } from '@/services/registry' +import type { AIAdapter, PlatformAdapter } from '@/services' + +function createMemoryStorage(): Storage { + const values = new Map() + return { + get length() { + return values.size + }, + clear: () => values.clear(), + getItem: (key) => values.get(key) ?? null, + key: (index) => [...values.keys()][index] ?? null, + removeItem: (key) => values.delete(key), + setItem: (key, value) => void values.set(key, value), + } +} + +test('locale-dependent desensitize rules do not wait for telemetry delivery', async () => { + Object.defineProperties(globalThis, { + localStorage: { configurable: true, value: createMemoryStorage() }, + sessionStorage: { configurable: true, value: createMemoryStorage() }, + window: { configurable: true, value: {} }, + }) + + let releaseTelemetry: (() => void) | undefined + const telemetryPending = new Promise((resolve) => { + releaseTelemetry = resolve + }) + const mergedLocales: string[] = [] + + registerAdapter('platform', { + trackDailyActive: () => telemetryPending, + } as unknown as PlatformAdapter) + registerAdapter('ai', { + mergeDesensitizeRules: async (_rules, locale) => { + mergedLocales.push(locale) + return [] + }, + } as unknown as AIAdapter) + + setActivePinia(createPinia()) + const { useSettingsStore } = await import('./settings') + const store = useSettingsStore() + + try { + const localeChange = store.setLocale('en-US') + await waitForImmediate() + + assert.deepEqual(mergedLocales, ['en-US']) + assert.equal( + await Promise.race([ + localeChange.then(() => true), + new Promise((resolve) => setTimeout(() => resolve(false), 20)), + ]), + true + ) + } finally { + releaseTelemetry?.() + } +}) diff --git a/src/stores/settings.ts b/src/stores/settings.ts index ed084b22..a60c2440 100644 --- a/src/stores/settings.ts +++ b/src/stores/settings.ts @@ -8,7 +8,7 @@ import 'dayjs/locale/ja' import { type LocaleType, setLocale as setI18nLocale, getLocale, getDayjsLocale } from '@/i18n' import type { PreprocessConfig } from '@electron/preload/index' import type { AIPreprocessConfig } from '@openchatlab/shared-types' -import { useAIService } from '@/services' +import { useAIService, usePlatformService } from '@/services' import { PLATFORM_CAPABILITIES } from '@/utils/platform-capabilities' const DESENSITIZE_RULES_SCHEMA_VERSION = 2 @@ -80,6 +80,14 @@ export const useSettingsStore = defineStore( window.electron?.ipcRenderer.send('locale:change', newLocale) if (PLATFORM_CAPABILITIES.initializesLlm) await ensureDesensitizeRules() + + try { + void usePlatformService() + .trackDailyActive(newLocale) + .catch(() => {}) + } catch { + // Analytics is best-effort and can be unavailable during early startup or in tests. + } } /**