mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-25 17:54:34 +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
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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 \}\}/)
|
||||
})
|
||||
@@ -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 内核:不打包也不声明依赖,
|
||||
|
||||
Reference in new issue
Block a user