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

@@ -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 内核:不打包也不声明依赖,