feat(server): add product analytics events (#1941)

This commit is contained in:
RainbowBird
2026-06-03 23:03:33 +08:00
committed by GitHub
parent 41e8cd7626
commit 7ac69db4ef
24 changed files with 3590 additions and 10 deletions
+13 -1
View File
@@ -2,6 +2,7 @@ import type { MessageRole, WireMessage } from '@proj-airi/server-sdk-shared'
import type { Database } from '../../libs/db'
import type { EngagementMetrics } from '../../otel'
import type { ProductEventService } from './product-events'
import { useLogger } from '@guiiai/logg'
import { and, eq, gt, inArray, isNull, sql } from 'drizzle-orm'
@@ -49,7 +50,7 @@ export function resolveSenderId(role: string, userId: string, characterId?: stri
// Service factory
// ---------------------------------------------------------------------------
export function createChatService(db: Database, metrics?: EngagementMetrics | null) {
export function createChatService(db: Database, metrics?: EngagementMetrics | null, productEventService?: ProductEventService) {
// ---- internal helpers ---------------------------------------------------
async function verifyMembership(tx: Parameters<Parameters<Database['transaction']>[0]>[0], chatId: string, userId: string) {
@@ -299,6 +300,17 @@ export function createChatService(db: Database, metrics?: EngagementMetrics | nu
if (result.totalCount > 0) {
metrics?.chatMessages.add(result.totalCount)
void productEventService?.track({
userId,
feature: 'chat',
action: 'message_pushed',
status: 'succeeded',
source: 'chat.ws.push_messages',
metadata: {
message_count: result.totalCount,
new_count: result.newCount,
},
})
}
metrics?.wsMessagesReceived.add(result.totalCount)
@@ -4,6 +4,7 @@ import type { FluxMeter } from '../billing/flux-meter'
import type { FluxService } from '../flux'
import type { LlmRouterService } from '../llm-router'
import type { startTtsGeneration, TtsGenerationTrace } from '../llm-tracing'
import type { ProductEventService } from '../product-events'
import type { RequestLogService } from '../request-log'
import { useLogger } from '@guiiai/logg'
@@ -32,6 +33,7 @@ export interface OpenAiSpeechServiceDeps {
requestLogService: RequestLogService
ttsMeter: FluxMeter
llmRouter: LlmRouterService
productEventService: ProductEventService
genAi?: GenAiMetrics | null
llmTracing: {
startTtsGeneration: (input: Parameters<typeof startTtsGeneration>[0]) => TtsGenerationTrace
@@ -78,6 +80,18 @@ export function createOpenAiSpeechService(deps: OpenAiSpeechServiceDeps) {
voice: typeof input.body.voice === 'string' ? input.body.voice : undefined,
}).log('tts speech request')
void deps.productEventService.track({
userId: input.userId,
feature: 'tts',
action: 'speech_requested',
status: 'started',
source: 'audio.speech',
model: requestModel,
metadata: {
input_chars: inputText.length,
},
})
const flux = await deps.fluxService.getFlux(input.userId)
if (flux.flux <= 0)
throw createPaymentRequiredError('Insufficient flux')
@@ -127,6 +141,19 @@ export function createOpenAiSpeechService(deps: OpenAiSpeechServiceDeps) {
provider: routeCtx.provider,
status: 502,
})
void deps.productEventService.track({
userId: input.userId,
feature: 'tts',
action: 'speech_failed',
status: 'failed',
source: 'audio.speech',
model: requestModel,
provider: routeCtx.provider,
reason: 'router_exhausted',
metadata: {
duration_ms: Date.now() - startedAt,
},
})
throw err
}
@@ -138,6 +165,20 @@ export function createOpenAiSpeechService(deps: OpenAiSpeechServiceDeps) {
span.end()
generationTrace.fail(`Gateway ${response.status}`)
recordMetrics({ model: requestModel, status: response.status, provider: routeCtx.provider, durationMs, fluxConsumed: 0 })
void deps.productEventService.track({
userId: input.userId,
feature: 'tts',
action: 'speech_failed',
status: 'failed',
source: 'audio.speech',
model: requestModel,
provider: routeCtx.provider,
reason: 'upstream_error',
metadata: {
http_status: response.status,
duration_ms: durationMs,
},
})
logger.withFields({ requestId, userId: input.userId, model: requestModel, status: response.status, durationMs })
.warn('tts speech delivered with upstream error status')
return new Response(response.body, {
@@ -172,6 +213,21 @@ export function createOpenAiSpeechService(deps: OpenAiSpeechServiceDeps) {
}
recordMetrics({ model: requestModel, status: response.status, provider: routeCtx.provider, durationMs, fluxConsumed })
void deps.productEventService.track({
userId: input.userId,
feature: 'tts',
action: 'speech_succeeded',
status: 'succeeded',
source: 'audio.speech',
model: requestModel,
provider: routeCtx.provider,
metadata: {
http_status: response.status,
input_chars: inputText.length,
duration_ms: durationMs,
flux_consumed: fluxConsumed,
},
})
deps.requestLogService.logRequest({
userId: input.userId,
model: requestModel,
@@ -0,0 +1,102 @@
import type { Database } from '../../libs/db'
import type { ProductMetrics } from '../../otel'
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { mockDB } from '../../libs/mock-db'
import { createProductEventService } from './product-events'
import * as schema from '../../schemas'
describe('productEventService', () => {
let db: Database
beforeAll(async () => {
db = await mockDB(schema)
})
beforeEach(async () => {
await db.delete(schema.productEvents)
})
it('writes first-party events and increments only low-cardinality metric labels', async () => {
const events = { add: vi.fn() }
const service = createProductEventService(db, { events } as unknown as ProductMetrics)
await service.track({
userId: 'user-1',
feature: 'gen_ai_chat',
action: 'completion_succeeded',
status: 'succeeded',
source: 'openai.chat.completions',
model: 'openrouter/anthropic/claude-sonnet-4',
provider: 'openrouter',
metadata: {
stream: false,
flux_consumed: 3,
},
})
const rows = await db.select().from(schema.productEvents)
expect(rows).toHaveLength(1)
expect(rows[0]).toMatchObject({
userId: 'user-1',
feature: 'gen_ai_chat',
action: 'completion_succeeded',
status: 'succeeded',
source: 'openai.chat.completions',
model: 'openrouter/anthropic/claude-sonnet-4',
provider: 'openrouter',
})
expect(events.add).toHaveBeenCalledWith(1, {
feature: 'gen_ai_chat',
action: 'completion_succeeded',
status: 'succeeded',
source: 'openai.chat.completions',
})
})
it('aggregates event volume and distinct users by feature/action/status', async () => {
const service = createProductEventService(db)
const createdAt = new Date('2026-06-03T00:00:00.000Z')
await service.track({
userId: 'user-1',
feature: 'tts',
action: 'speech_succeeded',
status: 'succeeded',
source: 'audio.speech',
createdAt,
})
await service.track({
userId: 'user-1',
feature: 'tts',
action: 'speech_succeeded',
status: 'succeeded',
source: 'audio.speech.ws',
createdAt,
})
await service.track({
userId: 'user-2',
feature: 'tts',
action: 'speech_succeeded',
status: 'succeeded',
source: 'audio.speech',
createdAt,
})
const rows = await service.countDistinctUsersByFeature({
from: new Date('2026-06-02T00:00:00.000Z'),
to: new Date('2026-06-04T00:00:00.000Z'),
})
expect(rows).toEqual([{
feature: 'tts',
action: 'speech_succeeded',
status: 'succeeded',
eventCount: 3,
distinctUsers: 2,
}])
})
})
@@ -0,0 +1,152 @@
import type { Database } from '../../libs/db'
import type { ProductMetrics } from '../../otel'
import type { ProductEventMetadata } from '../../schemas/product-events'
import { useLogger } from '@guiiai/logg'
import { and, asc, count, gte, lt, sql } from 'drizzle-orm'
import * as schema from '../../schemas/product-events'
const logger = useLogger('product-events')
export type ProductFeature = 'auth' | 'chat' | 'gen_ai_chat' | 'tts' | 'billing'
export type ProductEventStatus = 'started' | 'succeeded' | 'failed'
export type ProductAction
= | 'user_signed_up'
| 'session_started'
| 'message_pushed'
| 'completion_requested'
| 'completion_succeeded'
| 'completion_failed'
| 'speech_requested'
| 'speech_succeeded'
| 'speech_failed'
| 'checkout_started'
| 'payment_completed'
/**
* Product event fact written to AIRI's own Postgres analytics table.
*/
export interface ProductEventInput {
/** Better Auth user id. Kept in Postgres only; never emitted as a Prometheus label. */
userId: string
/** Bounded product area used for product dashboards and funnels. */
feature: ProductFeature
/** Bounded user/business action within the feature. */
action: ProductAction
/** Lifecycle state for the action. */
status: ProductEventStatus
/** Optional bounded route/surface label such as `openai.chat.completions`. */
source?: string
/** Optional model alias for DB-side drilldown. Do not expose as a Prometheus label. */
model?: string
/** Optional provider name for DB-side drilldown. */
provider?: string
/** Optional bounded failure reason or business outcome. */
reason?: string
/** Optional primitive metadata for product analysis. Avoid PII and raw prompts. */
metadata?: ProductEventMetadata
/** Override for tests/backfills. Defaults to database/server current time. */
createdAt?: Date
}
export interface ProductEventAggregateInput {
/** Inclusive lower time bound. */
from: Date
/** Exclusive upper time bound. Omit for open-ended queries. */
to?: Date
}
export interface ProductEventAggregateRow {
feature: string
action: string
status: string
eventCount: number
distinctUsers: number
}
/**
* Creates AIRI's first-party product analytics event writer.
*
* Use when:
* - Server-side product behavior has a user id and should be queryable by
* distinct users, funnels, or retention windows.
* - Grafana needs low-cardinality event volume while Postgres keeps user-level
* detail.
*
* Expects:
* - Callers pass only bounded `feature` / `action` / `status` values.
* - PII, prompts, request ids, sessions, and user ids are not written into
* Prometheus labels. User id is stored only in the DB row.
*
* Returns:
* - Best-effort event writer plus a DB aggregation helper for analytics jobs.
*/
export function createProductEventService(db: Database, metrics?: ProductMetrics | null) {
return {
async track(input: ProductEventInput): Promise<void> {
try {
await db.insert(schema.productEvents).values({
userId: input.userId,
feature: input.feature,
action: input.action,
status: input.status,
source: input.source,
model: input.model,
provider: input.provider,
reason: input.reason,
metadata: input.metadata,
createdAt: input.createdAt,
})
const attrs: Record<string, string> = {
feature: input.feature,
action: input.action,
status: input.status,
}
if (input.source)
attrs.source = input.source
metrics?.events.add(1, attrs)
}
catch (err) {
logger.withError(err).withFields({
userId: input.userId,
feature: input.feature,
action: input.action,
status: input.status,
}).warn('Failed to write product event; swallowing to protect caller')
}
},
async countDistinctUsersByFeature(input: ProductEventAggregateInput): Promise<ProductEventAggregateRow[]> {
const where = input.to
? and(gte(schema.productEvents.createdAt, input.from), lt(schema.productEvents.createdAt, input.to))
: gte(schema.productEvents.createdAt, input.from)
const rows = await db
.select({
feature: schema.productEvents.feature,
action: schema.productEvents.action,
status: schema.productEvents.status,
eventCount: count(),
distinctUsers: sql<number>`count(distinct ${schema.productEvents.userId})::int`,
})
.from(schema.productEvents)
.where(where)
.groupBy(schema.productEvents.feature, schema.productEvents.action, schema.productEvents.status)
.orderBy(asc(schema.productEvents.feature), asc(schema.productEvents.action), asc(schema.productEvents.status))
return rows.map(row => ({
feature: row.feature,
action: row.action,
status: row.status,
eventCount: Number(row.eventCount),
distinctUsers: Number(row.distinctUsers),
}))
},
}
}
export type ProductEventService = ReturnType<typeof createProductEventService>