feat(analytics): track CLI web daily active users via shared AnalyticsService

- Extract shared AnalyticsService to packages/node-runtime using direct
  Aptabase HTTP API (removes @aptabase/electron dependency)
- Add POST /_web/telemetry/track route for generic event reporting
- Desktop: migrate analytics.json from userData to systemDir (~/.chatlab/),
  trigger daily active from renderer via new IPC so user-selected locale is used
- CLI: inject APTABASE_APP_KEY at build time, create AnalyticsService on
  server start, report app_active with platform: cli-web
- Both platforms report user-selected locale via platform service abstraction

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
diguaandClaude Sonnet 4.6 committed 2026-06-23 22:58:22 +08:00
1 parent 46066d7a5f
commit c8cccbefd4
19 files changed
+206 -153

No files matched your search

+6
View File
@@ -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(),
+3
View File
@@ -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: {
+33 -132
View File
@@ -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<string, string | number>): 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))
}
+1 -4
View File
@@ -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())
-1
View File
@@ -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",
+3
View File
@@ -92,6 +92,9 @@ export const extendedApi = {
setAnalyticsEnabled: (enabled: boolean): Promise<{ success: boolean }> => {
return ipcRenderer.invoke('analytics:setEnabled', enabled)
},
trackDailyActive: (locale: string): Promise<void> => {
return ipcRenderer.invoke('analytics:trackDailyActive', locale)
},
/**
* 重启应用
*/
+1
View File
@@ -103,6 +103,7 @@ interface Api {
fetchRemoteConfig: (url: string) => Promise<{ success: boolean; data?: unknown; error?: string }>
getAnalyticsEnabled: () => Promise<boolean>
setAnalyticsEnabled: (enabled: boolean) => Promise<{ success: boolean }>
trackDailyActive: (locale: string) => Promise<void>
relaunch: () => Promise<void>
getOpenAtLogin: () => Promise<boolean>
setOpenAtLogin: (enabled: boolean) => Promise<{ success: boolean; error?: string }>
+4
View File
@@ -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<void>
showInFolder?: (filePath: string) => Promise<void>
+1
View File
@@ -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'
+4
View File
@@ -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)
}
@@ -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<string, string | number> } }>(
'/_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 }
}
)
}
@@ -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<string, unknown> } {
const data = new Map<string, unknown>()
@@ -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<string, unknown>).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<string, unknown>).authProfile = name
return name
+2 -1
View File
@@ -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'
@@ -0,0 +1,109 @@
import * as fs from 'fs'
import * as path from 'path'
const REGIONS: Record<string, string> = {
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<string, string | number>): Promise<void> {
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<string, string | number>): Promise<void> {
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)
}
}
-12
View File
@@ -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
+4
View File
@@ -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)
+4
View File
@@ -33,6 +33,10 @@ export class ElectronPlatformAdapter implements PlatformAdapter {
return window.api.app.setAnalyticsEnabled(enabled)
}
trackDailyActive(locale: string): Promise<void> {
return window.api.app.trackDailyActive(locale)
}
showOpenDialog(options: OpenDialogOptions): Promise<OpenDialogResult> {
return window.api.dialog.showOpenDialog(options as Electron.OpenDialogOptions)
}
+1
View File
@@ -47,6 +47,7 @@ export interface PlatformAdapter {
getAnalyticsEnabled(): Promise<boolean>
setAnalyticsEnabled(enabled: boolean): Promise<{ success: boolean }>
trackDailyActive(locale: string): Promise<void>
showOpenDialog(options: OpenDialogOptions): Promise<OpenDialogResult>
copyImageToClipboard(dataUrl: string): Promise<{ success: boolean; error?: string }>
+8
View File
@@ -52,6 +52,14 @@ export class WebPlatformAdapter implements PlatformAdapter {
return { success: false }
}
async trackDailyActive(locale: string): Promise<void> {
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<OpenDialogResult> {
return { canceled: true, filePaths: [] }
}