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
+1615 -162

No files matched your search

@@ -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 })
}
})
+305 -68
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()
data.enabled = enabled
this.save(data)
this.updateData((data) => {
data.enabled = enabled
}, 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,
}),
})
return resp.ok
} catch (e) {
console.error(`[Analytics] Failed to track ${eventName}:`, e)
return false
}
if (props?.app_locale !== undefined) this.setAppLocale(String(props.app_locale))
const event = normalizeEvent(eventName, props)
if (!event) return false
if (!this.options.umami) return false
const envelope = this.buildEnvelope(event)
const delivered = await this.sendToUmami(envelope)
appLogger.debug('analytics', delivered ? 'Analytics event delivered' : 'Analytics event was not delivered', {
eventName: envelope.name,
})
return delivered
}
async trackDailyActive(props?: Record<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