mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-26 02:04:37 +08:00
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:
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 })
|
||||
}
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
Reference in new issue
Block a user