feat(analytics): add privacy-safe product telemetry

Add self-hosted Umami reporting alongside existing telemetry across Desktop and CLI Web.

Track normalized startup, import, feature, and AI usage with a stable anonymous ID and strict privacy allowlists. Keep incremental usage platform-independent and preserve accurate failure classifications and platform metadata across import paths.
This commit is contained in:
digua committed 2026-07-29 14:28:48 +08:00
1 parent db60339829
commit 860bf52d62
48 files changed
+1613 -160

No files matched your search

+6 -3
View File
@@ -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
@@ -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 {
+2 -2
View File
@@ -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) {
+5 -3
View File
@@ -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,
+1 -1
View File
@@ -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)
})
+3 -2
View File
@@ -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 \}\}/)
})
+2 -1
View File
@@ -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 内核:不打包也不声明依赖,
+2 -1
View File
@@ -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'),
+18 -11
View File
@@ -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<string, unknown>) => {
getService()
?.track(eventName, properties)
.catch((error) => appLogger.error('analytics', `Failed to report ${eventName} event`, error))
})
}
export function trackAppEvent(eventName: string, properties?: Record<string, string | number>): void {
export function trackAppEvent(eventName: AnalyticsEventName, properties?: Record<string, unknown>): 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))
}
@@ -997,6 +997,7 @@ export interface IncrementalAnalyzeResult {
newMessageCount: number
duplicateCount: number
totalInFile: number
platform?: string
error?: string
}
+2
View File
@@ -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),
+4
View File
@@ -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<void> => {
return ipcRenderer.invoke('analytics:trackDailyActive', locale)
},
trackAnalyticsEvent: (eventName: AnalyticsEventName, properties?: Record<string, unknown>): Promise<void> => {
return ipcRenderer.invoke('analytics:track', eventName, properties)
},
/**
* 重启应用
*/
+4 -1
View File
@@ -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<boolean>
setAnalyticsEnabled: (enabled: boolean) => Promise<{ success: boolean }>
trackDailyActive: (locale: string) => Promise<void>
trackAnalyticsEvent: (eventName: AnalyticsEventName, properties?: Record<string, unknown>) => Promise<void>
relaunch: () => Promise<void>
getOpenAtLogin: () => Promise<boolean>
setOpenAtLogin: (enabled: boolean) => Promise<{ success: boolean; error?: string }>
@@ -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<string, unknown> }> = []
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()
}
})
})
@@ -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<AnalyticsService, 'getEnabled' | 'setEnabled' | 'trackDailyActive' | 'track'>
@@ -16,7 +17,7 @@ export function registerTelemetryRoutes(server: FastifyInstance, ctx: TelemetryR
return { success: true }
})
server.post<{ Body: { eventName: string; properties?: Record<string, string | number> } }>(
server.post<{ Body: { eventName: AnalyticsEventName; properties?: Record<string, unknown> } }>(
'/_web/telemetry/track',
async (req, reply) => {
if (!ctx.analyticsService) return { ok: true }
@@ -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', () => {
@@ -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
}
}
@@ -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 } })
@@ -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<AutoImportResult> {
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),
}
}
}
@@ -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)
@@ -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 ====================
@@ -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
@@ -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 ====================
@@ -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> = {}): 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<string, unknown>
}
}
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<string, unknown> }
}
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<string, unknown> }
}
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<string, unknown> }
}
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<Response>((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 })
}
})
+303 -66
View File
@@ -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<string, string> = {
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<string, AnalyticsPropertyValue>
}
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<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',
])
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<string, unknown> = {}): NormalizedEvent | null {
if (!EVENT_NAMES.has(eventName as AnalyticsEventName)) return null
const name = eventName as AnalyticsEventName
const properties: Record<string, AnalyticsPropertyValue> = {}
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<void> | 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<Partial<AnalyticsData>>(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<T>(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<string, AnalyticsPropertyValue> {
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<boolean> {
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()
this.updateData((data) => {
data.enabled = enabled
this.save(data)
}, undefined)
}
async track(eventName: string, props?: Record<string, string | number>): Promise<boolean> {
if (!this.endpoint) return false
setAppLocale(locale: string): void {
this.appLocale = normalizeLocale(locale)
}
async track(eventName: string, props?: Record<string, unknown>): Promise<boolean> {
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,
}),
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 resp.ok
} catch (e) {
console.error(`[Analytics] Failed to track ${eventName}:`, e)
return false
}
return delivered
}
async trackDailyActive(props?: Record<string, string | number>): Promise<void> {
if (!this.endpoint) return
trackDailyActive(props?: Record<string, unknown>): Promise<void> {
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<string, unknown>): Promise<void> {
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)
}
}
+19
View File
@@ -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
@@ -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,
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -400,7 +400,7 @@
"privacy": {
"title": "プライバシー設定",
"analytics": "匿名利用統計",
"analyticsDesc": "有効にすると、バージョン番号やOS情報などの非機密データを収集し、製品の改善に役立てます"
"analyticsDesc": "有効にすると、匿名のインストールID、バージョン、OS、アプリ種別、言語、おおよその地域、インポート元のチャットサービス、主要機能の利用状況を送信します。チャット内容、連絡先、ファイル名、パス、モデル名、キーは送信しません"
},
"developer": {
"title": "開発者オプション",
+1 -1
View File
@@ -400,7 +400,7 @@
"privacy": {
"title": "隐私设置",
"analytics": "匿名使用统计",
"analyticsDesc": "开启后,软件会收集版本号、操作系统版本等非敏感数据,用于帮助优化产品"
"analyticsDesc": "开启后,软件会发送匿名安装标识、版本、系统、运行方式、语言、粗略地区,以及导入平台和主要功能使用情况;不会发送聊天内容、联系人、文件名、路径、模型名称或密钥"
},
"developer": {
"title": "开发者选项",
+1 -1
View File
@@ -400,7 +400,7 @@
"privacy": {
"title": "隱私設定",
"analytics": "匿名使用統計",
"analyticsDesc": "開啟後,軟體會收集版本號、作業系統版本等非敏感資料,用於幫助最佳化產品"
"analyticsDesc": "開啟後,軟體會傳送匿名安裝識別碼、版本、系統、執行方式、語言、概略地區,以及匯入平台和主要功能使用情況;不會傳送聊天內容、聯絡人、檔名、路徑、模型名稱或金鑰"
},
"developer": {
"title": "開發者選項",
+21 -1
View File
@@ -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<AgentStreamResult> } {
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 }> {
+2
View File
@@ -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,
@@ -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,
})
})
})
+198
View File
@@ -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<string, unknown> }> = []
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<string, unknown> }> = []
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<string, unknown> }> = []
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<string, unknown> }> = []
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<string, unknown> }> = []
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<string, unknown> }> = []
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<string, unknown> }> = []
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<string, unknown> }> = []
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 }])
})
})
+201
View File
@@ -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<File, string>()
private readonly pathPlatforms = new Map<string, string>()
private readonly sourcePlatforms = new Map<string, string>()
constructor(
private readonly delegate: ImportAdapter,
private readonly platform: Pick<PlatformAdapter, 'trackAnalyticsEvent'>
) {}
private track(eventName: AnalyticsEventName, properties?: Record<string, unknown>): 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<T>): Promise<T> {
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<ImportResult> {
return this.runTrackedImport(this.rememberedPlatform(file, options), () =>
this.delegate.importFile(file, options, onProgress)
)
}
cancelActiveImport(): void {
this.delegate.cancelActiveImport?.()
}
async detectFormat(file: File | string): Promise<FormatInfo | null> {
const result = await this.delegate.detectFormat(file)
if (result) {
this.rememberPlatform(file, result.platform)
}
return result
}
scanMultiChatFile(file: File | string): Promise<MultiChatEntry[]> {
return this.delegate.scanMultiChatFile(file)
}
async prepareImportSource(file: File | string): Promise<PreparedImportSourceResult> {
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<ImportResult> {
return this.runTrackedImport(this.sourcePlatforms.get(sourceId) ?? 'unknown', () =>
this.delegate.importPreparedChat(sourceId, chatId, onProgress)
)
}
async releaseImportSource(sourceId: string): Promise<void> {
try {
await this.delegate.releaseImportSource(sourceId)
} finally {
this.sourcePlatforms.delete(sourceId)
}
}
getSupportedFormats(): Promise<FormatInfo[]> {
return this.delegate.getSupportedFormats()
}
importDemo(locale: string, onProgress?: (progress: DemoProgress) => void): Promise<DemoImportResult> {
return this.delegate.importDemo(locale, onProgress)
}
async analyzeIncrementalImport(sessionId: string, file: File | string): Promise<IncrementalAnalysis> {
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<IncrementalImportResult> {
this.track('incremental_import_used')
return this.delegate.incrementalImport(sessionId, file, onProgress)
}
importDirectory(
source: File[] | string,
options?: ImportOptions,
onProgress?: (progress: ImportProgress) => void
): Promise<ImportResult> {
const platform = platformFromFormatId(options?.formatId)
return this.runTrackedImport(platform, () => this.delegate.importDirectory(source, options, onProgress))
}
}
+3
View File
@@ -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
}
+6
View File
@@ -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<string, unknown>): Promise<void> {
void eventName
void properties
}
async showOpenDialog(_options: OpenDialogOptions): Promise<OpenDialogResult> {
return { canceled: true, filePaths: [] }
}
+22
View File
@@ -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[] = []
+10 -1
View File
@@ -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<string, unknown>): Promise<void> {
await fetchWithAuth('/_web/telemetry/track', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ eventName, properties }),
}).catch(() => {})
}
+5
View File
@@ -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<string, unknown>): Promise<void> {
return window.api.app.trackAnalyticsEvent(eventName, properties)
}
showOpenDialog(options: OpenDialogOptions): Promise<OpenDialogResult> {
return window.api.dialog.showOpenDialog(options as Electron.OpenDialogOptions)
}
+3
View File
@@ -1,3 +1,5 @@
import type { AnalyticsEventName } from '@openchatlab/shared-types'
/**
* PlatformAdapter — 平台能力领域适配器接口
*
@@ -48,6 +50,7 @@ export interface PlatformAdapter {
getAnalyticsEnabled(): Promise<boolean>
setAnalyticsEnabled(enabled: boolean): Promise<{ success: boolean }>
trackDailyActive(locale: string): Promise<void>
trackAnalyticsEvent(eventName: AnalyticsEventName, properties?: Record<string, unknown>): Promise<void>
showOpenDialog(options: OpenDialogOptions): Promise<OpenDialogResult>
copyImageToClipboard(dataUrl: string): Promise<{ success: boolean; error?: string }>
+34
View File
@@ -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<string, unknown> }> = []
await t.mock.module('@/i18n', {
namedExports: {
getLocale: () => 'zh-TW',
},
})
await t.mock.module('./platform/service', {
namedExports: {
usePlatformService: () => ({
trackAnalyticsEvent: async (name: AnalyticsEventName, properties?: Record<string, unknown>) => {
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' },
},
])
})
+13
View File
@@ -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<string, unknown>): 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.
}
}
+18 -10
View File
@@ -84,8 +84,15 @@ async function initElectronAdapters(): Promise<void> {
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<void> {
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<void> {
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<void> {
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())
+12
View File
@@ -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<boolean> {
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,
+64
View File
@@ -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<string, string>()
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<void>((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<boolean>((resolve) => setTimeout(() => resolve(false), 20)),
]),
true
)
} finally {
releaseTelemetry?.()
}
})
+9 -1
View File
@@ -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.
}
}
/**