fix(server): improve ai generation analytics correlation

This commit is contained in:
RainbowBird
2026-07-15 19:06:34 +08:00
parent 3543a60d3c
commit c74ed2ff7b
8 changed files with 171 additions and 18 deletions
@@ -180,6 +180,35 @@ Grafana 当前不能回答:
- 新用户 activation conversion 24h 环比下降超过 15%。
- `provider_mode = official` 的 activation failure 上升,优先排查官方 Provider。
### AI generation 用量事实
`$ai_generation` 是 token / usage / 成本覆盖率事实来源;不要把 Prompt、回复正文、API Key、raw endpoint 发到 PostHog。
| Field | Required | Notes |
|---|---|---|
| `$ai_trace_id` | yes | 优先使用真实 `conversation_id`;无客户端会话 header 时用 server request id 兜底 |
| `$ai_session_id` | yes | 与 `conversation_id` 保持一致 |
| `$ai_span_id` | yes | 与 `round_id` / generation id 保持一致 |
| `$ai_model` | yes | 原始生成模型;不要跨 Provider 合并 |
| `$ai_provider` | yes | 实际生成 Provider |
| `airi_user_id` | server yes | Better Auth user id,便于和 Pro / 付费事件关联 |
| `conversation_id` | yes | 始终存在;结合 `conversation_id_source` 判断是否真实应用会话 |
| `conversation_id_source` | yes | `client_header` / `client_runtime` / `server_request` |
| `round_id` | yes | 单轮或 request-level generation id |
| `app_surface` | when known | 只表示用户产品端:`web` / `electron` / `mobile`;不要用 `server` 兜底 |
| `capture_surface` | yes | 事件采集端:`client` / `server` |
| `usage_source` | yes | `reported` / `estimated` / `unavailable` |
| `token_usage_available` | yes | token 是否可用于聚合;日报 token 分析先过滤 `true` |
| `cost_usd_source` | yes | `reported` / `estimated` / `unavailable` |
| `cost_usd_known` | yes | `false` 不能按零成本处理,只能计入未知成本覆盖率 |
日报里的 Pro token / cost
- Token 总量、P50、P90:只统计 `token_usage_available = true`
- AIRI USD 成本:只统计 `cost_usd_known = true``cost_usd_known = false` 单独报 unknown generation count / coverage。
- 服务端 request fallback`conversation_id_source = server_request` 只能做 request 级 usage,不能和 `message_round` 当成同一应用会话 join。
- 模型分析按 `$ai_provider + $ai_model` 看;不要新增 `canonical_model` / `model_family` 把不同供应链揉在一起。
### Provider And Model Configuration
目标:定位配置复杂和失败劝退。
@@ -480,9 +509,9 @@ PostHog 线上已经能看到 `model` / `model_id` 存在自由文本风险。
- Provider、model、voice 使用稳定 ID。
- 自定义值不要直接 group-by。
- 不跨 Provider 合并模型名:`deepseek-chat``deepseek/deepseek-chat` 可能代表不同供应链 / 成本口径,日报按原始 Provider + model 组合看。
- 自定义模型传:
- `provider_id = custom`
- `model_family = custom`
- `is_custom_model = true`
- `custom_model_hash` 可选,必须单向 hash,不能还原原文。
- 自定义 voice 传:
@@ -6,9 +6,16 @@ export const AIRI_CHAT_APP_SURFACE_HEADER = 'x-airi-app-surface'
const CLIENT_CHAT_ANALYTICS_SURFACES = new Set<AiGenerationAppSurface>(['web', 'mobile', 'electron'])
export function resolveChatAnalyticsSurface(value: string | undefined): AiGenerationAppSurface {
/**
* Resolves the product runtime from a trusted client hint.
*
* Unknown values are not coerced to `server`: `$ai_generation` uses
* `capture_surface` for the process that emitted the event, while
* `app_surface` stays reserved for the user's actual product runtime.
*/
export function resolveChatAnalyticsSurface(value: string | undefined): AiGenerationAppSurface | undefined {
if (CLIENT_CHAT_ANALYTICS_SURFACES.has(value as AiGenerationAppSurface))
return value as AiGenerationAppSurface
return 'server'
return undefined
}
@@ -22,7 +22,7 @@ export interface ChatCompletionsOperationRequest {
body: Record<string, unknown>
sessionId?: string
roundId?: string
appSurface: AiGenerationAppSurface
appSurface?: AiGenerationAppSurface
abortSignal?: AbortSignal
}
@@ -32,7 +32,7 @@ interface GenerationCaptureInput {
requestId: string
sessionId?: string
roundId?: string
appSurface: AiGenerationAppSurface
appSurface?: AiGenerationAppSurface
generationModel: string
routeCtxProvider: string
usage: UsageInfo
@@ -240,13 +240,14 @@ interface ChatModelAliasPlan {
function captureGeneration(input: GenerationCaptureInput): void {
const generationId = input.roundId ?? input.requestId
const conversationId = input.sessionId ?? input.requestId
const totalTokens = input.usage.promptTokens != null && input.usage.completionTokens != null
? input.usage.promptTokens + input.usage.completionTokens
: undefined
input.deps.productEventService.trackGeneration({
userId: input.userId,
traceId: input.sessionId ?? input.requestId,
traceId: conversationId,
generationId,
model: input.generationModel,
provider: input.routeCtxProvider || 'unknown',
@@ -257,9 +258,12 @@ function captureGeneration(input: GenerationCaptureInput): void {
inputTokens: input.usage.promptTokens,
outputTokens: input.usage.completionTokens,
totalTokens,
conversationId: input.sessionId,
costUsdSource: 'unavailable',
conversationId,
conversationIdSource: input.sessionId ? 'client_header' : 'server_request',
roundId: generationId,
appSurface: input.appSurface,
...(input.appSurface && { appSurface: input.appSurface }),
captureSurface: 'server',
latencySeconds: input.durationMs / 1000,
stream: input.stream,
})
@@ -356,7 +360,7 @@ function streamChatCompletion(input: {
userId: string
sessionId?: string
roundId?: string
appSurface: AiGenerationAppSurface
appSurface?: AiGenerationAppSurface
requestModel: string
generationModel: string
routeCtxProvider: string
@@ -579,7 +583,7 @@ async function completeNonStreamingChat(input: {
userId: string
sessionId?: string
roundId?: string
appSurface: AiGenerationAppSurface
appSurface?: AiGenerationAppSurface
requestModel: string
generationModel: string
routeCtxProvider: string
@@ -974,14 +974,79 @@ describe('v1CompletionsRoutes', () => {
inputTokens: 1,
outputTokens: 2,
totalTokens: 3,
costUsdSource: 'unavailable',
conversationId: 'conversation-1',
conversationIdSource: 'client_header',
roundId: 'round-1',
appSurface: 'electron',
captureSurface: 'server',
latencySeconds: expect.any(Number),
stream: false,
})
})
it('uses request-level correlation for server-captured generations without chat headers', async () => {
const llmRouter = createMockLlmRouter({
route: vi.fn(async (_req, ctx) => {
if (ctx) {
ctx.provider = 'openrouter'
ctx.upstreamModel = 'openai/gpt-4o-mini'
}
return new Response(JSON.stringify({
choices: [],
usage: { prompt_tokens: 1, completion_tokens: 2, total_tokens: 3 },
}), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
}) as any,
})
const productEventService = createMockProductEventService()
const app = createTestApp(
createMockFluxService(),
createMockConfigKV(),
undefined,
undefined,
undefined,
llmRouter,
createMockLlmTracing(),
productEventService,
)
await app.fetch(
new Request('http://localhost/api/v1/openai/chat/completions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model: 'chat-auto', messages: [{ role: 'user', content: 'hi' }] }),
}),
{ user: testUser } as any,
)
expect(productEventService.trackGeneration).toHaveBeenCalledWith({
userId: 'user-1',
traceId: expect.any(String),
generationId: expect.any(String),
model: 'openai/gpt-4o-mini',
provider: 'openrouter',
providerType: 'official',
usageSource: 'reported',
inputTokens: 1,
outputTokens: 2,
totalTokens: 3,
costUsdSource: 'unavailable',
conversationId: expect.any(String),
conversationIdSource: 'server_request',
roundId: expect.any(String),
captureSurface: 'server',
latencySeconds: expect.any(Number),
stream: false,
})
const generation = vi.mocked(productEventService.trackGeneration).mock.calls[0]?.[0]
expect(generation?.traceId).toBe(generation?.conversationId)
expect(generation?.roundId).toBe(generation?.generationId)
expect(generation).not.toHaveProperty('appSurface')
})
it('should not charge flux when upstream returns error', async () => {
globalThis.fetch = vi.fn(async () => new Response('{"error":"bad"}', {
status: 500,
@@ -271,8 +271,10 @@ describe('productEventService', () => {
outputTokens: 8,
totalTokens: 20,
conversationId: 'session-1',
conversationIdSource: 'client_header',
roundId: 'round-1',
appSurface: 'server',
appSurface: 'electron',
captureSurface: 'server',
})
expect(capture).not.toHaveBeenCalled()
@@ -289,11 +291,17 @@ describe('productEventService', () => {
$ai_output_tokens: 8,
$ai_total_tokens: 20,
$insert_id: 'ai-generation:round-1',
airi_user_id: 'user-1',
provider_type: 'official',
usage_source: 'reported',
token_usage_available: true,
cost_usd_source: 'unavailable',
cost_usd_known: false,
conversation_id: 'session-1',
conversation_id_source: 'client_header',
round_id: 'round-1',
app_surface: 'server',
app_surface: 'electron',
capture_surface: 'server',
},
})
@@ -76,7 +76,17 @@ export interface ProductEventAggregateRow {
distinctUsers: number
}
export type AiGenerationAppSurface = 'server' | 'web' | 'mobile' | 'electron'
/** Product runtime where the user initiated the AI generation. */
export type AiGenerationAppSurface = 'web' | 'mobile' | 'electron'
/** Runtime that captured the `$ai_generation` fact. */
export type AiGenerationCaptureSurface = 'server' | 'client'
/** Explains whether `conversation_id` is an app conversation or a server fallback. */
export type AiGenerationConversationIdSource = 'client_header' | 'server_request'
/** Explains whether AIRI supplied a trustworthy USD cost for this generation. */
export type AiGenerationCostUsdSource = 'reported' | 'estimated' | 'unavailable'
/** Content-free PostHog AI generation fact keyed to the authenticated user. */
export interface AiGenerationEventInput {
@@ -90,9 +100,17 @@ export interface AiGenerationEventInput {
inputTokens?: number
outputTokens?: number
totalTokens?: number
conversationId?: string
totalCostUsd?: number
costUsdSource?: AiGenerationCostUsdSource
/** Always present for joins; `conversationIdSource` tells whether it is request-level fallback. */
conversationId: string
/** Distinguishes real client conversation ids from server-generated request fallbacks. */
conversationIdSource: AiGenerationConversationIdSource
roundId?: string
appSurface: AiGenerationAppSurface
/** Omitted when the server cannot determine the user's product runtime. */
appSurface?: AiGenerationAppSurface
/** Defaults to `server` because this service runs in the API process. */
captureSurface?: AiGenerationCaptureSurface
latencySeconds?: number
stream?: boolean
}
@@ -169,21 +187,28 @@ export function createProductEventService(db: Database, metrics?: ProductMetrics
event: '$ai_generation',
properties: {
$ai_trace_id: input.traceId,
...(input.conversationId && { $ai_session_id: input.conversationId }),
$ai_session_id: input.conversationId,
$ai_span_id: input.generationId,
$ai_model: input.model,
$ai_provider: input.provider,
...(input.inputTokens != null && { $ai_input_tokens: input.inputTokens }),
...(input.outputTokens != null && { $ai_output_tokens: input.outputTokens }),
...(input.totalTokens != null && { $ai_total_tokens: input.totalTokens }),
...(input.totalCostUsd != null && { $ai_total_cost_usd: input.totalCostUsd }),
...(input.latencySeconds != null && { $ai_latency: input.latencySeconds }),
...(input.stream != null && { $ai_stream: input.stream }),
$insert_id: `ai-generation:${input.generationId}`,
airi_user_id: input.userId,
provider_type: input.providerType,
usage_source: input.usageSource,
...(input.conversationId && { conversation_id: input.conversationId }),
token_usage_available: input.usageSource !== 'unavailable',
cost_usd_source: input.costUsdSource ?? 'unavailable',
cost_usd_known: input.totalCostUsd != null,
conversation_id: input.conversationId,
conversation_id_source: input.conversationIdSource,
...(input.roundId && { round_id: input.roundId }),
app_surface: input.appSurface,
...(input.appSurface && { app_surface: input.appSurface }),
capture_surface: input.captureSurface ?? 'server',
},
}
@@ -108,10 +108,15 @@ describe('useAnalytics conversation product events', () => {
$ai_total_tokens: 20,
$insert_id: 'ai-generation:round-1',
app_surface: 'web',
capture_surface: 'client',
conversation_id: 'session-1',
conversation_id_source: 'client_runtime',
round_id: 'round-1',
provider_type: 'custom',
usage_source: 'reported',
token_usage_available: true,
cost_usd_source: 'unavailable',
cost_usd_known: false,
})
})
@@ -135,10 +140,15 @@ describe('useAnalytics conversation product events', () => {
$ai_provider: 'ollama',
$insert_id: 'ai-generation:round-2',
app_surface: 'web',
capture_surface: 'client',
conversation_id: 'session-1',
conversation_id_source: 'client_runtime',
round_id: 'round-2',
provider_type: 'custom',
usage_source: 'unavailable',
token_usage_available: false,
cost_usd_source: 'unavailable',
cost_usd_known: false,
})
})
@@ -429,10 +429,15 @@ export function useAnalytics() {
...(totalTokens != null && { $ai_total_tokens: totalTokens }),
$insert_id: `ai-generation:${properties.round_id}`,
app_surface: getConversationAnalyticsSurface(),
capture_surface: 'client',
conversation_id: properties.conversation_id,
conversation_id_source: 'client_runtime',
round_id: properties.round_id,
provider_type: properties.provider_type,
usage_source: properties.usage_source,
token_usage_available: properties.usage_source !== 'unavailable',
cost_usd_source: 'unavailable',
cost_usd_known: false,
})
}