diff --git a/apps/cli/src/http/routes/web/index.ts b/apps/cli/src/http/routes/web/index.ts index 4a697209..1cc92a90 100644 --- a/apps/cli/src/http/routes/web/index.ts +++ b/apps/cli/src/http/routes/web/index.ts @@ -36,6 +36,7 @@ import { createNodeDataDirSwitch, getDefaultNodeUserDataDir, getPendingNodeDataDirMigration, + AnalyticsService, type SemanticIndexRuntime, } from '@openchatlab/node-runtime' import { registerSharedRoutes } from '@openchatlab/http-routes' @@ -103,6 +104,10 @@ 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 + registerSharedRoutes( server, { @@ -112,6 +117,7 @@ export function registerWebRoutes( getVersion, nativeBinding: options?.nativeBinding, semanticIndexService, + analyticsService, openDirectory: openDirectoryPath, showInFolder: showPathInFolder, downloadsDir: resolvedPathProvider.getDownloadsDir(), diff --git a/apps/cli/tsup.config.ts b/apps/cli/tsup.config.ts index d15e48ec..6c737ab9 100644 --- a/apps/cli/tsup.config.ts +++ b/apps/cli/tsup.config.ts @@ -14,6 +14,9 @@ export default defineConfig({ clean: true, target: 'node20', platform: 'node', + define: { + 'process.env.APTABASE_APP_KEY': JSON.stringify(process.env.APTABASE_APP_KEY || ''), + }, noExternal: [/^@openchatlab\//, 'chatlab-mcp', 'stream-json'], external: ['better-sqlite3', '@node-rs/jieba'], banner: { diff --git a/apps/desktop/main/analytics.ts b/apps/desktop/main/analytics.ts index ae11d7d2..5877c893 100644 --- a/apps/desktop/main/analytics.ts +++ b/apps/desktop/main/analytics.ts @@ -1,158 +1,59 @@ -/** - * 应用分析模块 - * 使用 Aptabase 进行匿名使用统计 - */ - import { app, ipcMain } from 'electron' -import { initialize, trackEvent } from '@aptabase/electron/main' import * as fs from 'fs' import * as path from 'path' +import { AnalyticsService } from '@openchatlab/node-runtime' +import { getSystemDataDir } from './paths' -// AppKey -const ANALYTICS_APP_KEY = process.env.APTABASE_APP_KEY +const APTABASE_APP_KEY = process.env.APTABASE_APP_KEY -// 分析数据存储路径 -function getAnalyticsPath(): string { - return path.join(app.getPath('userData'), 'analytics.json') -} +let _service: AnalyticsService | null = null -// 分析数据结构 -interface AnalyticsData { - lastReportDate: string | null - firstReportDate: string | null // 用于判断新老用户 - enabled: boolean // 是否启用统计 -} - -// 默认配置 -const defaultAnalyticsData: AnalyticsData = { - lastReportDate: null, - firstReportDate: null, - enabled: true, // 默认启用 -} - -// 读取分析数据 -function loadAnalyticsData(): AnalyticsData { +function migrateAnalyticsIfNeeded(systemDir: string): void { + const newPath = path.join(systemDir, 'analytics.json') + if (fs.existsSync(newPath)) return try { - const filePath = getAnalyticsPath() - if (fs.existsSync(filePath)) { - const data = fs.readFileSync(filePath, 'utf-8') - return { ...defaultAnalyticsData, ...JSON.parse(data) } - } - } catch (error) { - console.error('[Analytics] Failed to read analytics data:', error) - } - return { ...defaultAnalyticsData } -} - -// 保存分析数据 -function saveAnalyticsData(data: AnalyticsData): void { - try { - const filePath = getAnalyticsPath() - fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf-8') - } catch (error) { - console.error('[Analytics] Failed to save analytics data:', error) + const oldPath = path.join(app.getPath('userData'), 'analytics.json') + if (fs.existsSync(oldPath)) fs.copyFileSync(oldPath, newPath) + } catch (_e) { + // Non-critical migration, ignore errors } } -// 获取今天的日期字符串 (YYYY-MM-DD) -function getTodayString(): string { - const now = new Date() - return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}` +function getService(): AnalyticsService | null { + if (!APTABASE_APP_KEY) return null + if (!_service) { + const systemDir = getSystemDataDir() + migrateAnalyticsIfNeeded(systemDir) + _service = new AnalyticsService(systemDir, APTABASE_APP_KEY, app.getVersion()) + } + return _service } -/** - * 初始化分析模块 - * 必须在 app.whenReady() 之前调用 - */ export function initAnalytics(): void { - if (!ANALYTICS_APP_KEY) { - return - } - - try { - initialize(ANALYTICS_APP_KEY) - console.log('[Analytics] Aptabase initialized') - } catch (error) { - console.error('[Analytics] Failed to initialize Aptabase:', error) - } + // Service is initialized lazily; no-op here unless we want eager validation. + if (!APTABASE_APP_KEY) return + getService() } -/** - * 注册 Analytics IPC 处理器 - */ export function registerAnalyticsHandlers(): void { - // 获取统计启用状态 ipcMain.handle('analytics:getEnabled', () => { - return loadAnalyticsData().enabled + return getService()?.getEnabled() ?? true }) - // 设置统计启用状态 ipcMain.handle('analytics:setEnabled', (_, enabled: boolean) => { - const data = loadAnalyticsData() - data.enabled = enabled - saveAnalyticsData(data) + getService()?.setEnabled(enabled) return { success: true } }) + + ipcMain.handle('analytics:trackDailyActive', (_, locale: string) => { + getService() + ?.trackDailyActive({ platform: 'desktop', locale }) + .catch((e) => console.error('[Analytics] Failed to report daily active:', e)) + }) } -/** - * 上报每日活跃事件 - */ -export function trackDailyActive(): void { - if (!ANALYTICS_APP_KEY) { - return - } - - try { - const data = loadAnalyticsData() - - // 检查是否启用统计 - if (!data.enabled) { - return - } - - const today = getTodayString() - const isNew = data.firstReportDate === null - - // 新用户记录首次使用日期 - if (isNew) { - data.firstReportDate = today - } - - // 检查今天是否已经上报过 - if (data.lastReportDate === today) { - if (isNew) { - saveAnalyticsData(data) - } - return - } - - // 上报每日活跃事件 - trackEvent(isNew ? 'app_active_new' : 'app_active', { locale: app.getLocale() }) - - data.lastReportDate = today - saveAnalyticsData(data) - } catch (error) { - console.error('[Analytics] Failed to report daily active:', error) - } -} - -/** - * 事件上报 - */ export function trackAppEvent(eventName: string, properties?: Record): void { - if (!ANALYTICS_APP_KEY) { - return - } - - // 检查是否启用统计 - if (!loadAnalyticsData().enabled) { - return - } - - try { - trackEvent(eventName, properties) - } catch (error) { - console.error(`[Analytics] Failed to report event ${eventName}:`, error) - } + getService() + ?.track(eventName, properties) + .catch((e) => console.error(`[Analytics] Failed to report event ${eventName}:`, e)) } diff --git a/apps/desktop/main/index.ts b/apps/desktop/main/index.ts index 73673245..4b88595f 100644 --- a/apps/desktop/main/index.ts +++ b/apps/desktop/main/index.ts @@ -5,7 +5,7 @@ import { checkUpdate } from './update' import mainIpcMain, { cleanup } from './ipcMain' import { startInternalServer, stopInternalServer, registerInternalApiIpc } from './internal-api' import { getPathProvider } from './path-context' -import { initAnalytics, trackDailyActive } from './analytics' +import { initAnalytics } from './analytics' import { initProxy } from './network/proxy' import { needsLegacyMigration, @@ -306,9 +306,6 @@ class MainProcess { // 设置Windows应用程序用户模型id if (process.platform === 'win32') app.setAppUserModelId(app.getName()) - // 记录日活(用于统计操作系统版本、客户端版本,便于更好的适配客户端) - trackDailyActive() - // 启动 Internal API Server(硬依赖:失败则退出应用) try { await startInternalServer(getPathProvider()) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 9665123e..e371b795 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -14,7 +14,6 @@ "build:win": "pnpm run build && electron-builder --win --config electron-builder.yml -p never" }, "dependencies": { - "@aptabase/electron": "^0.3.1", "@electron-toolkit/preload": "^3.0.1", "@electron-toolkit/utils": "^4.0.0", "@fastify/multipart": "^10.0.0", diff --git a/apps/desktop/preload/apis/core.ts b/apps/desktop/preload/apis/core.ts index 2d547c6d..7719ed49 100644 --- a/apps/desktop/preload/apis/core.ts +++ b/apps/desktop/preload/apis/core.ts @@ -92,6 +92,9 @@ export const extendedApi = { setAnalyticsEnabled: (enabled: boolean): Promise<{ success: boolean }> => { return ipcRenderer.invoke('analytics:setEnabled', enabled) }, + trackDailyActive: (locale: string): Promise => { + return ipcRenderer.invoke('analytics:trackDailyActive', locale) + }, /** * 重启应用 */ diff --git a/apps/desktop/preload/index.d.ts b/apps/desktop/preload/index.d.ts index ba8c3aa0..04e4fe56 100644 --- a/apps/desktop/preload/index.d.ts +++ b/apps/desktop/preload/index.d.ts @@ -103,6 +103,7 @@ interface Api { fetchRemoteConfig: (url: string) => Promise<{ success: boolean; data?: unknown; error?: string }> getAnalyticsEnabled: () => Promise setAnalyticsEnabled: (enabled: boolean) => Promise<{ success: boolean }> + trackDailyActive: (locale: string) => Promise relaunch: () => Promise getOpenAtLogin: () => Promise setOpenAtLogin: (enabled: boolean) => Promise<{ success: boolean; error?: string }> diff --git a/packages/http-routes/src/context.ts b/packages/http-routes/src/context.ts index f739a7ff..e0a89220 100644 --- a/packages/http-routes/src/context.ts +++ b/packages/http-routes/src/context.ts @@ -9,6 +9,7 @@ import type { PathProvider } from '@openchatlab/core' import type { ChartAutoMode } from '@openchatlab/shared-types' import type { AuthProfile } from '@openchatlab/config' +import type { AnalyticsService } from '@openchatlab/node-runtime' import type { DatabaseManager, DataDirSwitchResult, @@ -66,6 +67,9 @@ export interface HttpRouteContext { resolveApiKey?: (provider: string, authProfile?: string) => string writeAuthProfile?: (name: string, profile: AuthProfile) => void + /** Analytics tracking service — optional, telemetry routes silently skip when absent */ + analyticsService?: AnalyticsService + /** Cache/storage — platform-specific (optional) */ openDirectory?: (dirPath: string) => Promise showInFolder?: (filePath: string) => Promise diff --git a/packages/http-routes/src/index.ts b/packages/http-routes/src/index.ts index f88b22f1..bfcc0624 100644 --- a/packages/http-routes/src/index.ts +++ b/packages/http-routes/src/index.ts @@ -39,3 +39,4 @@ export { registerAiSummaryRoutes } from './routes/web/ai-summaries' export { registerAiLlmStreamRoutes } from './routes/web/ai-llm-stream' export { registerAiAgentStreamRoutes } from './routes/web/ai-agent-stream' export { registerAiToolRoutes } from './routes/web/ai-tools' +export { registerTelemetryRoutes } from './routes/web/telemetry' diff --git a/packages/http-routes/src/register.ts b/packages/http-routes/src/register.ts index ad2c187e..df495d95 100644 --- a/packages/http-routes/src/register.ts +++ b/packages/http-routes/src/register.ts @@ -29,6 +29,7 @@ import { registerAiSummaryRoutes } from './routes/web/ai-summaries' import { registerSemanticIndexRoutes } from './routes/web/ai-semantic-index' import { registerMergeRoutes } from './routes/web/merge' import { registerCacheRoutes } from './routes/web/cache' +import { registerTelemetryRoutes } from './routes/web/telemetry' export interface SharedRouteOptions { /** When true, AI routes will throw on missing dependencies instead of silently skipping */ @@ -90,4 +91,7 @@ export function registerSharedRoutes( // Cache/storage routes registerCacheRoutes(server, resolvedCtx) + + // Telemetry routes + registerTelemetryRoutes(server, resolvedCtx) } diff --git a/packages/http-routes/src/routes/web/telemetry.ts b/packages/http-routes/src/routes/web/telemetry.ts new file mode 100644 index 00000000..27f310a9 --- /dev/null +++ b/packages/http-routes/src/routes/web/telemetry.ts @@ -0,0 +1,19 @@ +import type { FastifyInstance } from 'fastify' +import type { HttpRouteContext } from '../../context' + +export function registerTelemetryRoutes(server: FastifyInstance, ctx: HttpRouteContext): void { + server.post<{ Body: { eventName: string; properties?: Record } }>( + '/_web/telemetry/track', + async (req, reply) => { + if (!ctx.analyticsService) return { ok: true } + const { eventName, properties } = req.body ?? {} + if (!eventName) return reply.code(400).send({ error: 'eventName required' }) + if (eventName === 'app_active') { + await ctx.analyticsService.trackDailyActive(properties) + } else { + await ctx.analyticsService.track(eventName, properties) + } + 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 b14c358f..a8e0160b 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 @@ -1,6 +1,6 @@ import { describe, it, beforeEach } from 'node:test' import assert from 'node:assert/strict' -import { LLMConfigStore, type ConfigStorage, type AIConfigStore } from '../llm-config-store' +import { LLMConfigStore, type ConfigStorage, type AIConfigStore, type AIServiceConfig } from '../llm-config-store' function createMemoryStorage(): ConfigStorage & { data: Map } { const data = new Map() @@ -204,7 +204,7 @@ describe('LLMConfigStore', () => { const deleted: string[] = [] const storeWithHook = new LLMConfigStore(storage, { generateId: () => `id-${++idCounter}`, - onApiKeyCreated: (config, apiKey) => { + onApiKeyCreated: (config, _apiKey) => { const name = config.name.toLowerCase().replace(/\s+/g, '-') ;(config as unknown as Record).authProfile = name return name @@ -224,7 +224,7 @@ describe('LLMConfigStore', () => { const deleted: string[] = [] const storeWithHook = new LLMConfigStore(storage, { generateId: () => `id-${++idCounter}`, - onApiKeyCreated: (config, apiKey) => { + onApiKeyCreated: (config, _apiKey) => { const name = config.name.toLowerCase().replace(/\s+/g, '-') ;(config as unknown as Record).authProfile = name return name diff --git a/packages/node-runtime/src/index.ts b/packages/node-runtime/src/index.ts index d45ac4a2..8c7ec1de 100644 --- a/packages/node-runtime/src/index.ts +++ b/packages/node-runtime/src/index.ts @@ -339,7 +339,8 @@ export { Type, completeSimple, streamSimple, runSimpleLlmStream } from './ai' export type { LlmStreamChunk, RunSimpleLlmStreamOptions } from './ai' export type { PiModel, PiApi, PiMessage, PiUsage, PiTextContent, PiAssistantMessage } from './ai' -// Shared application services (session / member / index / summary / export) +// Shared application services (session / member / index / summary / export / analytics) +export { AnalyticsService } from './services/analytics' export * as sessionService from './services/session-service' export * as memberService from './services/member-service' export * as sessionIndexService from './services/session-index-service' diff --git a/packages/node-runtime/src/services/analytics.ts b/packages/node-runtime/src/services/analytics.ts new file mode 100644 index 00000000..9de9bad0 --- /dev/null +++ b/packages/node-runtime/src/services/analytics.ts @@ -0,0 +1,109 @@ +import * as fs from 'fs' +import * as path from 'path' + +const REGIONS: Record = { + US: 'https://us.aptabase.com', + EU: 'https://eu.aptabase.com', +} + +interface AnalyticsData { + lastReportDate: string | null + firstReportDate: string | null + enabled: boolean +} + +const DEFAULT_DATA: AnalyticsData = { lastReportDate: null, firstReportDate: null, enabled: true } + +export class AnalyticsService { + private readonly dataPath: string + private readonly endpoint: string | null + private readonly appKey: string + private readonly appVersion: string + private readonly sessionId: string + + constructor(systemDir: string, appKey: string, appVersion: string) { + 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 + } + + 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 + } + return { ...DEFAULT_DATA } + } + + private save(data: AnalyticsData): void { + try { + fs.writeFileSync(this.dataPath, JSON.stringify(data, null, 2), 'utf-8') + } catch (e) { + console.error('[Analytics] Failed to save:', e) + } + } + + getEnabled(): boolean { + return this.load().enabled + } + + setEnabled(enabled: boolean): void { + const data = this.load() + data.enabled = enabled + this.save(data) + } + + async track(eventName: string, props?: Record): Promise { + if (!this.endpoint) return + if (!this.getEnabled()) return + try { + 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, + }), + }) + } catch (e) { + console.error(`[Analytics] Failed to track ${eventName}:`, e) + } + } + + async trackDailyActive(props?: Record): Promise { + if (!this.endpoint) return + const data = this.load() + if (!data.enabled) return + 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 + } + await this.track(isNew ? 'app_active_new' : 'app_active', props) + data.lastReportDate = today + this.save(data) + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 792db7ee..de1f64ea 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -173,9 +173,6 @@ importers: apps/desktop: dependencies: - '@aptabase/electron': - specifier: ^0.3.1 - version: 0.3.1(electron@35.7.5) '@electron-toolkit/preload': specifier: ^3.0.1 version: 3.0.2(electron@35.7.5) @@ -457,11 +454,6 @@ packages: zod: optional: true - '@aptabase/electron@0.3.1': - resolution: {integrity: sha512-FECaGsjuoQu70F+M6V1evdgLP7yaq/sne9fC60AZZ6B9RsCDlxFBnJzdq3+xevHlUx6AB5o9ygUhQ+ONT9EqiA==} - peerDependencies: - electron: '>= 3.x' - '@aws-crypto/crc32@5.2.0': resolution: {integrity: sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==} engines: {node: '>=16.0.0'} @@ -6165,10 +6157,6 @@ snapshots: optionalDependencies: zod: 3.25.76 - '@aptabase/electron@0.3.1(electron@35.7.5)': - dependencies: - electron: 35.7.5 - '@aws-crypto/crc32@5.2.0': dependencies: '@aws-crypto/util': 5.2.0 diff --git a/src/App.vue b/src/App.vue index d7491edb..db6d54b4 100644 --- a/src/App.vue +++ b/src/App.vue @@ -20,6 +20,7 @@ import { initPreferencesSync } from '@/composables/usePreferencesSync' import { useWindowsTitleBarOverlay } from '@/composables/useWindowsTitleBarOverlay' import { configureHttpClient } from '@/services/utils/http' import { IS_ELECTRON } from '@/utils/platform' +import { usePlatformService } from '@/services' const { t } = useI18n() @@ -61,6 +62,9 @@ async function initializeApp() { await settingsStore.initLocale() llmStore.init() await sessionStore.loadSessions() + usePlatformService() + .trackDailyActive(settingsStore.locale) + .catch(() => {}) } catch (err) { console.error('App initialization failed:', err) initError.value = err instanceof Error ? err.message : String(err) diff --git a/src/services/platform/electron.ts b/src/services/platform/electron.ts index 67bce39f..9434233d 100644 --- a/src/services/platform/electron.ts +++ b/src/services/platform/electron.ts @@ -33,6 +33,10 @@ export class ElectronPlatformAdapter implements PlatformAdapter { return window.api.app.setAnalyticsEnabled(enabled) } + trackDailyActive(locale: string): Promise { + return window.api.app.trackDailyActive(locale) + } + 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 ff171b84..cbf302ca 100644 --- a/src/services/platform/types.ts +++ b/src/services/platform/types.ts @@ -47,6 +47,7 @@ export interface PlatformAdapter { getAnalyticsEnabled(): Promise setAnalyticsEnabled(enabled: boolean): Promise<{ success: boolean }> + trackDailyActive(locale: string): Promise showOpenDialog(options: OpenDialogOptions): Promise copyImageToClipboard(dataUrl: string): Promise<{ success: boolean; error?: string }> diff --git a/src/services/platform/web.ts b/src/services/platform/web.ts index c2a48a95..e63c68b2 100644 --- a/src/services/platform/web.ts +++ b/src/services/platform/web.ts @@ -52,6 +52,14 @@ export class WebPlatformAdapter implements PlatformAdapter { return { success: false } } + async trackDailyActive(locale: string): Promise { + await fetchWithAuth('/_web/telemetry/track', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ eventName: 'app_active', properties: { platform: 'cli-web', locale } }), + }).catch(() => {}) + } + async showOpenDialog(_options: OpenDialogOptions): Promise { return { canceled: true, filePaths: [] } }