feat(server): add product analytics events (#1941)
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
CREATE TABLE "product_events" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"user_id" text NOT NULL,
|
||||
"feature" text NOT NULL,
|
||||
"action" text NOT NULL,
|
||||
"status" text NOT NULL,
|
||||
"source" text,
|
||||
"model" text,
|
||||
"provider" text,
|
||||
"reason" text,
|
||||
"metadata" jsonb,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX "product_events_feature_action_created_at_idx" ON "product_events" USING btree ("feature","action","created_at");--> statement-breakpoint
|
||||
CREATE INDEX "product_events_user_id_created_at_idx" ON "product_events" USING btree ("user_id","created_at");--> statement-breakpoint
|
||||
CREATE INDEX "product_events_created_at_idx" ON "product_events" USING btree ("created_at");
|
||||
File diff suppressed because it is too large
Load Diff
@@ -99,6 +99,13 @@
|
||||
"when": 1779728850192,
|
||||
"tag": "0013_naive_groot",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 14,
|
||||
"version": "7",
|
||||
"when": 1780498188307,
|
||||
"tag": "0014_vengeful_blonde_phantom",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -51,6 +51,10 @@ function createTestDeps() {
|
||||
adminUsersService: {} as any,
|
||||
ttsMeter: {} as any,
|
||||
requestLogService: {} as any,
|
||||
productEventService: {
|
||||
track: vi.fn(async () => undefined),
|
||||
countDistinctUsersByFeature: vi.fn(async () => []),
|
||||
},
|
||||
configKV: {
|
||||
getOrThrow: vi.fn(async (key: string) => {
|
||||
switch (key) {
|
||||
|
||||
+18
-5
@@ -16,6 +16,7 @@ import type { ChatService } from './services/domain/chats'
|
||||
import type { FluxService } from './services/domain/flux'
|
||||
import type { FluxTransactionService } from './services/domain/flux-transaction'
|
||||
import type { LlmRouterService } from './services/domain/llm-router'
|
||||
import type { ProductEventService } from './services/domain/product-events'
|
||||
import type { ProviderService } from './services/domain/providers'
|
||||
import type { RequestLogService } from './services/domain/request-log'
|
||||
import type { StripeService } from './services/domain/stripe'
|
||||
@@ -75,6 +76,7 @@ import { createChatService } from './services/domain/chats'
|
||||
import { createFluxService } from './services/domain/flux'
|
||||
import { createFluxTransactionService } from './services/domain/flux-transaction'
|
||||
import { createConfigSyncSubscriber, createLlmRouterService } from './services/domain/llm-router'
|
||||
import { createProductEventService } from './services/domain/product-events'
|
||||
import { createProviderService } from './services/domain/providers'
|
||||
import { createRequestLogService } from './services/domain/request-log'
|
||||
import { createStripeService } from './services/domain/stripe'
|
||||
@@ -99,6 +101,7 @@ interface AppDeps {
|
||||
adminUsersService: AdminUsersService
|
||||
ttsMeter: FluxMeter
|
||||
requestLogService: RequestLogService
|
||||
productEventService: ProductEventService
|
||||
configKV: ConfigKVService
|
||||
envelopeCrypto: EnvelopeCrypto
|
||||
redis: Redis
|
||||
@@ -186,6 +189,7 @@ export async function buildApp(deps: AppDeps) {
|
||||
fluxService: deps.fluxService,
|
||||
ttsMeter: deps.ttsMeter,
|
||||
requestLogService: deps.requestLogService,
|
||||
productEventService: deps.productEventService,
|
||||
})
|
||||
app.get('/api/v1/audio/speech/ws', upgradeWebSocket(async (c) => {
|
||||
const token = c.req.query('token')
|
||||
@@ -221,11 +225,13 @@ export async function buildApp(deps: AppDeps) {
|
||||
billingService: deps.billingService,
|
||||
configKV: deps.configKV,
|
||||
requestLogService: deps.requestLogService,
|
||||
productEventService: deps.productEventService,
|
||||
ttsMeter: deps.ttsMeter,
|
||||
llmRouter: deps.llmRouter,
|
||||
genAi: deps.otel?.genAi,
|
||||
revenue: deps.otel?.revenue,
|
||||
rateLimitMetrics: deps.otel?.rateLimit,
|
||||
posthog: deps.posthog,
|
||||
})
|
||||
|
||||
const builtApp = app
|
||||
@@ -351,7 +357,7 @@ export async function buildApp(deps: AppDeps) {
|
||||
/**
|
||||
* Stripe routes.
|
||||
*/
|
||||
.route('/api/v1/stripe', createStripeRoutes(deps.fluxService, deps.stripeService, deps.billingService, deps.configKV, deps.env, deps.redis, deps.otel?.revenue, deps.otel?.rateLimit, deps.posthog))
|
||||
.route('/api/v1/stripe', createStripeRoutes(deps.fluxService, deps.stripeService, deps.billingService, deps.configKV, deps.env, deps.redis, deps.otel?.revenue, deps.otel?.rateLimit, deps.posthog, deps.productEventService))
|
||||
|
||||
/**
|
||||
* Admin routes — guarded by the `adminGuard` role check (`role === 'admin'`,
|
||||
@@ -519,6 +525,11 @@ export async function createApp() {
|
||||
},
|
||||
})
|
||||
|
||||
const productEventService = injeca.provide('services:productEvents', {
|
||||
dependsOn: { db, otel },
|
||||
build: ({ dependsOn }) => createProductEventService(dependsOn.db, dependsOn.otel?.product),
|
||||
})
|
||||
|
||||
const characterService = injeca.provide('services:characters', {
|
||||
dependsOn: { db, otel },
|
||||
build: ({ dependsOn }) => createCharacterService(dependsOn.db, dependsOn.otel?.engagement),
|
||||
@@ -530,8 +541,8 @@ export async function createApp() {
|
||||
})
|
||||
|
||||
const chatService = injeca.provide('services:chats', {
|
||||
dependsOn: { db, otel },
|
||||
build: ({ dependsOn }) => createChatService(dependsOn.db, dependsOn.otel?.engagement),
|
||||
dependsOn: { db, otel, productEventService },
|
||||
build: ({ dependsOn }) => createChatService(dependsOn.db, dependsOn.otel?.engagement, dependsOn.productEventService),
|
||||
})
|
||||
|
||||
const stripeService = injeca.provide('services:stripe', {
|
||||
@@ -579,7 +590,7 @@ export async function createApp() {
|
||||
})
|
||||
|
||||
const auth = injeca.provide('services:auth', {
|
||||
dependsOn: { db, env: parsedEnv, otel, email: emailService, userDeletionService, posthog },
|
||||
dependsOn: { db, env: parsedEnv, otel, email: emailService, userDeletionService, posthog, productEventService },
|
||||
build: async ({ dependsOn }) => {
|
||||
// Seed trusted OIDC clients into DB so FK constraints on oauth_access_token are satisfied
|
||||
await seedTrustedClients(dependsOn.db, dependsOn.env)
|
||||
@@ -592,7 +603,7 @@ export async function createApp() {
|
||||
redirectUris: client.redirectUris.join(', '),
|
||||
}).log('OIDC trusted client ready')
|
||||
}
|
||||
return createAuth(dependsOn.db, dependsOn.env, dependsOn.email, dependsOn.otel?.auth, dependsOn.userDeletionService, dependsOn.posthog)
|
||||
return createAuth(dependsOn.db, dependsOn.env, dependsOn.email, dependsOn.otel?.auth, dependsOn.userDeletionService, dependsOn.posthog, dependsOn.productEventService)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -688,6 +699,7 @@ export async function createApp() {
|
||||
fluxService,
|
||||
fluxTransactionService,
|
||||
requestLogService,
|
||||
productEventService,
|
||||
stripeService,
|
||||
billingService,
|
||||
adminFluxGrantsService,
|
||||
@@ -735,6 +747,7 @@ export async function createApp() {
|
||||
adminUsersService: resolved.adminUsersService,
|
||||
ttsMeter: resolved.ttsMeter,
|
||||
requestLogService: resolved.requestLogService,
|
||||
productEventService: resolved.productEventService,
|
||||
configKV: resolved.configKV,
|
||||
envelopeCrypto: resolved.envelopeCrypto,
|
||||
redis: resolved.redis,
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { PostHog } from 'posthog-node'
|
||||
|
||||
import type { AuthMetrics } from '../otel'
|
||||
import type { EmailService } from '../services/adapters/email'
|
||||
import type { ProductEventService } from '../services/domain/product-events'
|
||||
import type { UserDeletionService } from '../services/domain/user-deletion'
|
||||
import type { Database } from './db'
|
||||
import type { Env } from './env'
|
||||
@@ -365,6 +366,7 @@ export function createAuth(
|
||||
metrics?: AuthMetrics | null,
|
||||
userDeletionService?: UserDeletionService,
|
||||
posthog?: PostHog | null,
|
||||
productEventService?: ProductEventService,
|
||||
) {
|
||||
return betterAuth({
|
||||
secret: env.BETTER_AUTH_SECRET,
|
||||
@@ -663,6 +665,13 @@ export function createAuth(
|
||||
create: {
|
||||
after: async (user) => {
|
||||
metrics?.userRegistered.add(1)
|
||||
await productEventService?.track({
|
||||
userId: user.id,
|
||||
feature: 'auth',
|
||||
action: 'user_signed_up',
|
||||
status: 'succeeded',
|
||||
source: 'better-auth.user.create',
|
||||
})
|
||||
await captureSafe(posthog ?? null, {
|
||||
event: 'user_signed_up',
|
||||
distinctId: user.id,
|
||||
@@ -701,6 +710,13 @@ export function createAuth(
|
||||
.update(authSchema.user)
|
||||
.set({ lastSeenAt: new Date() })
|
||||
.where(eq(authSchema.user.id, session.userId))
|
||||
await productEventService?.track({
|
||||
userId: session.userId,
|
||||
feature: 'auth',
|
||||
action: 'session_started',
|
||||
status: 'succeeded',
|
||||
source: 'better-auth.session.create',
|
||||
})
|
||||
await captureSafe(posthog ?? null, {
|
||||
event: 'session_started',
|
||||
distinctId: session.userId,
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
METRIC_AIRI_GEN_AI_GATEWAY_UPSTREAM_ERRORS,
|
||||
METRIC_AIRI_GEN_AI_STREAM_INTERRUPTED,
|
||||
METRIC_AIRI_OBSERVABILITY_READ_ERRORS,
|
||||
METRIC_AIRI_PRODUCT_EVENTS,
|
||||
METRIC_AIRI_RATE_LIMIT_BLOCKED,
|
||||
METRIC_AIRI_STRIPE_REVENUE,
|
||||
METRIC_AIRI_TTS_CHARS,
|
||||
@@ -299,6 +300,21 @@ export interface ObservabilityMetrics {
|
||||
metricReadErrors: Counter
|
||||
}
|
||||
|
||||
export interface ProductMetrics {
|
||||
/**
|
||||
* Low-cardinality product event counter.
|
||||
*
|
||||
* Use when:
|
||||
* - Reporting feature/event volume in Prometheus and Grafana.
|
||||
*
|
||||
* Expects:
|
||||
* - Labels stay bounded (`feature`, `action`, `status`, optional
|
||||
* `source`). Never attach `user_id`, `session_id`, request ids, models
|
||||
* with unbounded aliases, or free-form error messages here.
|
||||
*/
|
||||
events: Counter
|
||||
}
|
||||
|
||||
export interface OtelInstance {
|
||||
auth: AuthMetrics
|
||||
engagement: EngagementMetrics
|
||||
@@ -308,6 +324,7 @@ export interface OtelInstance {
|
||||
email: EmailMetrics
|
||||
rateLimit: RateLimitMetrics
|
||||
observability: ObservabilityMetrics
|
||||
product: ProductMetrics
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -511,6 +528,12 @@ export function initOtel(env: Env): OtelInstance | null {
|
||||
}),
|
||||
}
|
||||
|
||||
const product: ProductMetrics = {
|
||||
events: meter.createCounter(METRIC_AIRI_PRODUCT_EVENTS, {
|
||||
description: 'Low-cardinality product event volume. Distinct users live in Postgres product_events, not Prometheus labels.',
|
||||
}),
|
||||
}
|
||||
|
||||
// NOTICE:
|
||||
// OTel SDK only emits a Counter time series after .add() runs the first time.
|
||||
// Without this priming step, low-traffic counters (auth_failures_total,
|
||||
@@ -560,10 +583,11 @@ export function initOtel(env: Env): OtelInstance | null {
|
||||
email.failures,
|
||||
rateLimit.blocked,
|
||||
observability.metricReadErrors,
|
||||
product.events,
|
||||
]
|
||||
for (const counter of counters) counter.add(0)
|
||||
|
||||
return { auth, engagement, revenue, genAi, gateway, email, rateLimit, observability }
|
||||
return { auth, engagement, revenue, genAi, gateway, email, rateLimit, observability, product }
|
||||
}
|
||||
|
||||
const severityMap: Record<string, SeverityNumber> = {
|
||||
|
||||
@@ -166,6 +166,10 @@ function makeFakeDeps(overrides: {
|
||||
const requestLogService = {
|
||||
logRequest: vi.fn(async () => undefined),
|
||||
}
|
||||
const productEventService = {
|
||||
track: vi.fn(async () => undefined),
|
||||
countDistinctUsersByFeature: vi.fn(async () => []),
|
||||
}
|
||||
const configKV = {
|
||||
getOptional: vi.fn(async (key: string) => {
|
||||
if (key === 'UNSPEECH_UPSTREAM') {
|
||||
@@ -185,7 +189,7 @@ function makeFakeDeps(overrides: {
|
||||
decryptKey: vi.fn(() => Buffer.from(overrides.decryptedKey ?? 'mock-upstream-token', 'utf8')),
|
||||
}
|
||||
|
||||
return { configKV, envelopeCrypto, fluxService, ttsMeter, requestLogService }
|
||||
return { configKV, envelopeCrypto, fluxService, ttsMeter, requestLogService, productEventService }
|
||||
}
|
||||
|
||||
/** Drives the WSEvents lifecycle as if a real client had connected. */
|
||||
|
||||
@@ -91,6 +91,15 @@ export function createSessionState(userId: string, opts: AudioSpeechWsHandlersOp
|
||||
}
|
||||
|
||||
async function dialUpstream() {
|
||||
void opts.productEventService.track({
|
||||
userId,
|
||||
feature: 'tts',
|
||||
action: 'speech_requested',
|
||||
status: 'started',
|
||||
source: 'audio.speech.ws',
|
||||
model: modelLabel,
|
||||
})
|
||||
|
||||
let unspeech: Awaited<ReturnType<AudioSpeechWsHandlersOptions['configKV']['getOptional']>>
|
||||
try {
|
||||
unspeech = await opts.configKV.getOptional('UNSPEECH_UPSTREAM')
|
||||
@@ -191,6 +200,18 @@ export function createSessionState(userId: string, opts: AudioSpeechWsHandlersOp
|
||||
log.withError(err).withFields({ userId }).warn('upstream ws error')
|
||||
span.recordException(err)
|
||||
span.setStatus({ code: SpanStatusCode.ERROR, message: err.message })
|
||||
void opts.productEventService.track({
|
||||
userId,
|
||||
feature: 'tts',
|
||||
action: 'speech_failed',
|
||||
status: 'failed',
|
||||
source: 'audio.speech.ws',
|
||||
model: modelLabel,
|
||||
reason: 'upstream_error',
|
||||
metadata: {
|
||||
duration_ms: Date.now() - startedAt,
|
||||
},
|
||||
})
|
||||
try {
|
||||
clientWs?.send(JSON.stringify({
|
||||
event: 'error',
|
||||
@@ -383,6 +404,20 @@ export function createSessionState(userId: string, opts: AudioSpeechWsHandlersOp
|
||||
log.withError(err).warn('failed to write request log for streaming tts')
|
||||
}
|
||||
|
||||
void opts.productEventService.track({
|
||||
userId,
|
||||
feature: 'tts',
|
||||
action: 'speech_succeeded',
|
||||
status: 'succeeded',
|
||||
source: 'audio.speech.ws',
|
||||
model: modelLabel,
|
||||
metadata: {
|
||||
input_chars: units,
|
||||
duration_ms: durationMs,
|
||||
flux_consumed: fluxConsumed,
|
||||
},
|
||||
})
|
||||
|
||||
finalize()
|
||||
}
|
||||
|
||||
@@ -405,6 +440,19 @@ export function createSessionState(userId: string, opts: AudioSpeechWsHandlersOp
|
||||
if (closed)
|
||||
return
|
||||
span.setStatus({ code: SpanStatusCode.ERROR, message: reason })
|
||||
void opts.productEventService.track({
|
||||
userId,
|
||||
feature: 'tts',
|
||||
action: 'speech_failed',
|
||||
status: 'failed',
|
||||
source: 'audio.speech.ws',
|
||||
model: modelLabel,
|
||||
reason,
|
||||
metadata: {
|
||||
close_code: code,
|
||||
duration_ms: Date.now() - startedAt,
|
||||
},
|
||||
})
|
||||
if (clientWs) {
|
||||
try {
|
||||
clientWs.send(JSON.stringify({ event: 'error', code: reason, message: reason }))
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { ConfigKVService } from '../../services/adapters/config-kv'
|
||||
import type { FluxMeter } from '../../services/domain/billing/flux-meter'
|
||||
import type { FluxService } from '../../services/domain/flux'
|
||||
import type { ProductEventService } from '../../services/domain/product-events'
|
||||
import type { RequestLogService } from '../../services/domain/request-log'
|
||||
import type { EnvelopeCrypto } from '../../utils/envelope-crypto'
|
||||
|
||||
@@ -18,4 +19,6 @@ export interface AudioSpeechWsHandlersOptions {
|
||||
ttsMeter: FluxMeter
|
||||
/** Persists request accounting after a stream finishes. */
|
||||
requestLogService: RequestLogService
|
||||
/** Writes first-party product analytics for distinct-user aggregation. */
|
||||
productEventService: ProductEventService
|
||||
}
|
||||
|
||||
@@ -55,6 +55,18 @@ export function chatCompletions(deps: V1RouteDeps): GatewayCallback<'chat.comple
|
||||
stream,
|
||||
messageCount: Array.isArray(body.messages) ? body.messages.length : undefined,
|
||||
}).log('chat completion request')
|
||||
void deps.productEventService.track({
|
||||
userId: input.userId,
|
||||
feature: 'gen_ai_chat',
|
||||
action: 'completion_requested',
|
||||
status: 'started',
|
||||
source: 'openai.chat.completions',
|
||||
model: requestModel,
|
||||
metadata: {
|
||||
stream,
|
||||
message_count: Array.isArray(body.messages) ? body.messages.length : null,
|
||||
},
|
||||
})
|
||||
|
||||
// Server-connection attrs come from the router (which knows the actual
|
||||
// upstream baseURL it dispatched to) — it enriches the active span with
|
||||
@@ -90,6 +102,20 @@ export function chatCompletions(deps: V1RouteDeps): GatewayCallback<'chat.comple
|
||||
sessionId: input.sessionId,
|
||||
}).fail('Router exhausted or unknown model')
|
||||
telemetry.recordMetrics({ model: requestModel, status: 502, type: 'chat', provider: routeCtx.provider, durationMs: Date.now() - startedAt, fluxConsumed: 0 })
|
||||
void deps.productEventService.track({
|
||||
userId: input.userId,
|
||||
feature: 'gen_ai_chat',
|
||||
action: 'completion_failed',
|
||||
status: 'failed',
|
||||
source: 'openai.chat.completions',
|
||||
model: requestModel,
|
||||
provider: routeCtx.provider,
|
||||
reason: 'router_exhausted',
|
||||
metadata: {
|
||||
duration_ms: Date.now() - startedAt,
|
||||
stream,
|
||||
},
|
||||
})
|
||||
throw err
|
||||
}
|
||||
|
||||
@@ -115,6 +141,21 @@ export function chatCompletions(deps: V1RouteDeps): GatewayCallback<'chat.comple
|
||||
telemetry.failSpan(span, `Gateway ${response.status}`)
|
||||
generationTrace.fail(`Gateway ${response.status}`)
|
||||
telemetry.recordMetrics({ model: requestModel, status: response.status, type: 'chat', provider: routeCtx.provider, durationMs, fluxConsumed: 0 })
|
||||
void deps.productEventService.track({
|
||||
userId: input.userId,
|
||||
feature: 'gen_ai_chat',
|
||||
action: 'completion_failed',
|
||||
status: 'failed',
|
||||
source: 'openai.chat.completions',
|
||||
model: requestModel,
|
||||
provider: routeCtx.provider,
|
||||
reason: 'upstream_error',
|
||||
metadata: {
|
||||
http_status: response.status,
|
||||
duration_ms: durationMs,
|
||||
stream,
|
||||
},
|
||||
})
|
||||
// Emit server-side so funnels see real HTTP status — the client only
|
||||
// ever observes "stream closed" and cannot tell 401 / 429 / 5xx apart.
|
||||
void captureSafe(deps.posthog ?? null, {
|
||||
@@ -254,6 +295,21 @@ function streamChatCompletion(input: {
|
||||
input.telemetry.endSpan(input.span)
|
||||
input.generationTrace.fail('Gateway stream interrupted')
|
||||
input.telemetry.recordMetrics({ model: input.requestModel, status: input.response.status, type: 'chat', provider: input.routeCtxProvider, durationMs: input.durationMs, fluxConsumed: 0 })
|
||||
void input.deps.productEventService.track({
|
||||
userId: input.userId,
|
||||
feature: 'gen_ai_chat',
|
||||
action: 'completion_failed',
|
||||
status: 'failed',
|
||||
source: 'openai.chat.completions',
|
||||
model: input.requestModel,
|
||||
provider: input.routeCtxProvider,
|
||||
reason: 'stream_interrupted',
|
||||
metadata: {
|
||||
http_status: input.response.status,
|
||||
duration_ms: input.durationMs,
|
||||
stream: true,
|
||||
},
|
||||
})
|
||||
}
|
||||
else if (streamCompleted) {
|
||||
try {
|
||||
@@ -326,6 +382,23 @@ function streamChatCompletion(input: {
|
||||
promptTokens: usage.promptTokens,
|
||||
completionTokens: usage.completionTokens,
|
||||
})
|
||||
void input.deps.productEventService.track({
|
||||
userId: input.userId,
|
||||
feature: 'gen_ai_chat',
|
||||
action: 'completion_succeeded',
|
||||
status: 'succeeded',
|
||||
source: 'openai.chat.completions',
|
||||
model: input.requestModel,
|
||||
provider: input.routeCtxProvider,
|
||||
metadata: {
|
||||
http_status: input.response.status,
|
||||
duration_ms: input.durationMs,
|
||||
prompt_tokens: usage.promptTokens ?? 0,
|
||||
completion_tokens: usage.completionTokens ?? 0,
|
||||
flux_consumed: actualCharged,
|
||||
stream: true,
|
||||
},
|
||||
})
|
||||
|
||||
void captureSafe(input.deps.posthog ?? null, {
|
||||
distinctId: input.userId,
|
||||
@@ -390,6 +463,21 @@ async function completeNonStreamingChat(input: {
|
||||
input.telemetry.failSpan(input.span, 'Failed to parse upstream response body')
|
||||
input.generationTrace.fail('Failed to parse upstream response body')
|
||||
input.telemetry.recordMetrics({ model: input.requestModel, status: input.response.status, type: 'chat', provider: input.routeCtxProvider, durationMs: input.durationMs, fluxConsumed: 0 })
|
||||
void input.deps.productEventService.track({
|
||||
userId: input.userId,
|
||||
feature: 'gen_ai_chat',
|
||||
action: 'completion_failed',
|
||||
status: 'failed',
|
||||
source: 'openai.chat.completions',
|
||||
model: input.requestModel,
|
||||
provider: input.routeCtxProvider,
|
||||
reason: 'malformed_upstream_response',
|
||||
metadata: {
|
||||
http_status: input.response.status,
|
||||
duration_ms: input.durationMs,
|
||||
stream: false,
|
||||
},
|
||||
})
|
||||
throw err
|
||||
}
|
||||
const usage = extractUsageFromBody(responseBody)
|
||||
@@ -428,6 +516,23 @@ async function completeNonStreamingChat(input: {
|
||||
promptTokens: usage.promptTokens,
|
||||
completionTokens: usage.completionTokens,
|
||||
})
|
||||
void input.deps.productEventService.track({
|
||||
userId: input.userId,
|
||||
feature: 'gen_ai_chat',
|
||||
action: 'completion_succeeded',
|
||||
status: 'succeeded',
|
||||
source: 'openai.chat.completions',
|
||||
model: input.requestModel,
|
||||
provider: input.routeCtxProvider,
|
||||
metadata: {
|
||||
http_status: input.response.status,
|
||||
duration_ms: input.durationMs,
|
||||
prompt_tokens: usage.promptTokens ?? 0,
|
||||
completion_tokens: usage.completionTokens ?? 0,
|
||||
flux_consumed: actualCharged,
|
||||
stream: false,
|
||||
},
|
||||
})
|
||||
|
||||
void captureSafe(input.deps.posthog ?? null, {
|
||||
distinctId: input.userId,
|
||||
|
||||
@@ -17,6 +17,7 @@ export function speechGeneration(deps: V1RouteDeps): GatewayCallback<'speech.gen
|
||||
genAi: deps.genAi,
|
||||
llmRouter: deps.llmRouter,
|
||||
llmTracing: deps.llmTracing,
|
||||
productEventService: deps.productEventService,
|
||||
requestLogService: deps.requestLogService,
|
||||
ttsMeter: deps.ttsMeter,
|
||||
})
|
||||
|
||||
@@ -142,6 +142,10 @@ function createTestApp(
|
||||
billingService: billingService ?? createMockBillingService(),
|
||||
configKV,
|
||||
requestLogService: requestLogService ?? createMockRequestLogService(),
|
||||
productEventService: {
|
||||
track: vi.fn(async () => undefined),
|
||||
countDistinctUsersByFeature: vi.fn(async () => []),
|
||||
},
|
||||
ttsMeter: ttsMeter ?? createMockTtsMeter(),
|
||||
llmRouter: llmRouter ?? createMockLlmRouter(),
|
||||
genAi: null,
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { FluxMeter } from '../../../services/domain/billing/flux-meter'
|
||||
import type { FluxService } from '../../../services/domain/flux'
|
||||
import type { LlmRouterService } from '../../../services/domain/llm-router'
|
||||
import type { ChatGenerationTrace, TtsGenerationTrace } from '../../../services/domain/llm-tracing'
|
||||
import type { ProductEventService } from '../../../services/domain/product-events'
|
||||
import type { RequestLogService } from '../../../services/domain/request-log'
|
||||
|
||||
import { startChatGeneration, startTtsGeneration } from '../../../services/domain/llm-tracing'
|
||||
@@ -21,6 +22,7 @@ export interface V1RouteDeps {
|
||||
billingService: BillingService
|
||||
configKV: ConfigKVService
|
||||
requestLogService: RequestLogService
|
||||
productEventService: ProductEventService
|
||||
ttsMeter: FluxMeter
|
||||
llmRouter: LlmRouterService
|
||||
genAi?: GenAiMetrics | null
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { RateLimitMetrics, RevenueMetrics } from '../../otel'
|
||||
import type { ConfigKVService } from '../../services/adapters/config-kv'
|
||||
import type { BillingService } from '../../services/domain/billing/billing-service'
|
||||
import type { FluxService } from '../../services/domain/flux'
|
||||
import type { ProductEventService } from '../../services/domain/product-events'
|
||||
import type { StripeService } from '../../services/domain/stripe'
|
||||
import type { HonoEnv } from '../../types/hono'
|
||||
|
||||
@@ -47,10 +48,11 @@ export function createStripeRoutes(
|
||||
metrics?: RevenueMetrics | null,
|
||||
rateLimitMetrics?: RateLimitMetrics | null,
|
||||
posthog?: PostHog | null,
|
||||
productEventService?: ProductEventService,
|
||||
) {
|
||||
const stripe = env.STRIPE_SECRET_KEY ? new Stripe(env.STRIPE_SECRET_KEY) : null
|
||||
const priceCatalog = stripe ? createStripePriceCatalog(stripe, redis) : null
|
||||
const checkout = createCheckoutOperation({ stripe, priceCatalog, stripeService, configKV, env, metrics })
|
||||
const checkout = createCheckoutOperation({ stripe, priceCatalog, stripeService, configKV, env, metrics, productEventService })
|
||||
const webhook = createWebhookOperation({
|
||||
stripe,
|
||||
webhookSecret: env.STRIPE_WEBHOOK_SECRET,
|
||||
@@ -59,6 +61,7 @@ export function createStripeRoutes(
|
||||
billingService,
|
||||
metrics,
|
||||
posthog,
|
||||
productEventService,
|
||||
})
|
||||
|
||||
return new Hono<HonoEnv>()
|
||||
|
||||
@@ -3,6 +3,7 @@ import type Stripe from 'stripe'
|
||||
import type { Env } from '../../../libs/env'
|
||||
import type { RevenueMetrics } from '../../../otel'
|
||||
import type { ConfigKVService } from '../../../services/adapters/config-kv'
|
||||
import type { ProductEventService } from '../../../services/domain/product-events'
|
||||
import type { StripeService } from '../../../services/domain/stripe'
|
||||
import type { HonoEnv } from '../../../types/hono'
|
||||
import type { StripePriceCatalog } from '../price-catalog'
|
||||
@@ -23,6 +24,7 @@ export interface CheckoutOperationDeps {
|
||||
configKV: ConfigKVService
|
||||
env: Env
|
||||
metrics?: RevenueMetrics | null
|
||||
productEventService?: ProductEventService
|
||||
}
|
||||
|
||||
export interface CheckoutOperationInput {
|
||||
@@ -121,6 +123,18 @@ export function createCheckoutOperation(deps: CheckoutOperationDeps) {
|
||||
})
|
||||
|
||||
deps.metrics?.stripeCheckoutCreated.add(1)
|
||||
void deps.productEventService?.track({
|
||||
userId: input.user.id,
|
||||
feature: 'billing',
|
||||
action: 'checkout_started',
|
||||
status: 'succeeded',
|
||||
source: 'stripe.checkout',
|
||||
metadata: {
|
||||
flux_amount: fluxAmount,
|
||||
amount_total: session.amount_total,
|
||||
currency: session.currency,
|
||||
},
|
||||
})
|
||||
|
||||
return { url: session.url }
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import type Stripe from 'stripe'
|
||||
import type { RevenueMetrics } from '../../../otel'
|
||||
import type { BillingService } from '../../../services/domain/billing/billing-service'
|
||||
import type { FluxService } from '../../../services/domain/flux'
|
||||
import type { ProductEventService } from '../../../services/domain/product-events'
|
||||
import type { StripeService } from '../../../services/domain/stripe'
|
||||
|
||||
import { useLogger } from '@guiiai/logg'
|
||||
@@ -22,6 +23,7 @@ export interface WebhookOperationDeps {
|
||||
billingService: BillingService
|
||||
metrics?: RevenueMetrics | null
|
||||
posthog?: PostHog | null
|
||||
productEventService?: ProductEventService
|
||||
}
|
||||
|
||||
export interface WebhookOperationInput {
|
||||
@@ -80,8 +82,25 @@ export function createWebhookOperation(deps: WebhookOperationDeps) {
|
||||
// is the Better Auth user id so it merges with the browser's
|
||||
// `posthog.identify(userId)` and the prior `checkout_started`
|
||||
// event lines up. See docs/ai-context/metrics-ownership.md.
|
||||
if (result.processed)
|
||||
if (result.processed) {
|
||||
const userId = event.data.object.metadata?.userId
|
||||
if (userId) {
|
||||
const fluxAmount = Number(event.data.object.metadata?.fluxAmount)
|
||||
void deps.productEventService?.track({
|
||||
userId,
|
||||
feature: 'billing',
|
||||
action: 'payment_completed',
|
||||
status: 'succeeded',
|
||||
source: 'stripe.webhook',
|
||||
metadata: {
|
||||
amount_total: event.data.object.amount_total,
|
||||
currency: event.data.object.currency,
|
||||
flux_amount: Number.isFinite(fluxAmount) ? fluxAmount : null,
|
||||
},
|
||||
})
|
||||
}
|
||||
await capturePaymentCompleted(deps.posthog, event.data.object)
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'customer.created':
|
||||
|
||||
@@ -4,6 +4,7 @@ export * from './chats'
|
||||
export * from './flux'
|
||||
export * from './flux-transaction'
|
||||
export * from './llm-request-log'
|
||||
export * from './product-events'
|
||||
export * from './providers'
|
||||
export * from './stripe'
|
||||
export * from './user-character'
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { InferInsertModel, InferSelectModel } from 'drizzle-orm'
|
||||
|
||||
import { index, jsonb, pgTable, text, timestamp } from 'drizzle-orm/pg-core'
|
||||
|
||||
import { nanoid } from '../utils/id'
|
||||
|
||||
export type ProductEventMetadata = Record<string, string | number | boolean | null>
|
||||
|
||||
export const productEvents = pgTable(
|
||||
'product_events',
|
||||
{
|
||||
id: text('id').primaryKey().$defaultFn(() => nanoid()),
|
||||
userId: text('user_id').notNull(),
|
||||
feature: text('feature').notNull(),
|
||||
action: text('action').notNull(),
|
||||
status: text('status').notNull(),
|
||||
source: text('source'),
|
||||
model: text('model'),
|
||||
provider: text('provider'),
|
||||
reason: text('reason'),
|
||||
metadata: jsonb('metadata').$type<ProductEventMetadata>(),
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
},
|
||||
table => [
|
||||
index('product_events_feature_action_created_at_idx').on(table.feature, table.action, table.createdAt),
|
||||
index('product_events_user_id_created_at_idx').on(table.userId, table.createdAt),
|
||||
index('product_events_created_at_idx').on(table.createdAt),
|
||||
],
|
||||
)
|
||||
|
||||
export type ProductEvent = InferSelectModel<typeof productEvents>
|
||||
export type NewProductEvent = InferInsertModel<typeof productEvents>
|
||||
@@ -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>
|
||||
@@ -97,6 +97,11 @@ export const METRIC_AIRI_TTS_PREFLIGHT_REJECTIONS = 'airi.billing.tts.preflight_
|
||||
// AIRI observability — self-monitoring for the metric pipeline
|
||||
export const METRIC_AIRI_OBSERVABILITY_READ_ERRORS = 'airi.observability.read_errors'
|
||||
|
||||
// Product analytics — low-cardinality event volume only. User-level product
|
||||
// analytics live in Postgres `product_events`; never add user identifiers to
|
||||
// this metric's labels.
|
||||
export const METRIC_AIRI_PRODUCT_EVENTS = 'airi.product.events'
|
||||
|
||||
// AIRI revenue — actual money in (smallest currency unit, e.g. cents)
|
||||
export const METRIC_AIRI_STRIPE_REVENUE = 'airi.stripe.revenue'
|
||||
|
||||
|
||||
Reference in New Issue
Block a user