feat(server): add product analytics events (#1941)
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user