feat(analytics): add p0 operations events

This commit is contained in:
RainbowBird
2026-07-01 20:16:06 +08:00
parent 27255001bd
commit 0daac71435
8 changed files with 649 additions and 32 deletions
@@ -3,7 +3,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 { ProductAction, ProductEventService } from '../../../services/domain/product-events'
import type { StripeService } from '../../../services/domain/stripe'
import { useLogger } from '@guiiai/logg'
@@ -13,6 +13,16 @@ import { errorMessageFromUnknown } from '../../../utils/error-message'
const logger = useLogger('stripe')
interface StripeSubscriptionEventContext {
userId: string
stripeCustomerId: string
stripeSubscriptionId: string
stripePriceId?: string
subscriptionStatus?: string
amountPaid?: number
currency?: string
}
export interface WebhookOperationDeps {
stripe: Stripe | null
webhookSecret: string | undefined
@@ -103,15 +113,29 @@ export function createWebhookOperation(deps: WebhookOperationDeps) {
case 'customer.subscription.created':
case 'customer.subscription.updated':
case 'customer.subscription.deleted': {
await handleSubscriptionEvent(event.data.object, deps.stripeService)
const result = await handleSubscriptionEvent(event.data.object, deps.stripeService)
deps.metrics?.stripeSubscriptionEvent.add(1, { event_type: event.type.replace('customer.subscription.', '') })
const action = subscriptionActionForWebhookEvent(event.type)
if (result && action) {
void deps.productEventService?.track({
userId: result.userId,
feature: 'billing',
action,
status: 'succeeded',
source: 'stripe.webhook',
metadata: {
stripe_price_id: result.stripePriceId ?? null,
stripe_subscription_status: result.subscriptionStatus ?? null,
},
})
}
break
}
case 'invoice.created':
case 'invoice.updated':
case 'invoice.paid':
case 'invoice.payment_failed': {
await handleInvoiceEvent(event.data.object, deps.stripeService)
const result = await handleInvoiceEvent(event.data.object, deps.stripeService)
if (event.type === 'invoice.payment_failed')
deps.metrics?.stripePaymentFailed.add(1)
if (event.type === 'invoice.paid' && event.data.object.amount_paid && event.data.object.currency) {
@@ -120,6 +144,20 @@ export function createWebhookOperation(deps: WebhookOperationDeps) {
source: 'invoice',
})
}
if (event.type === 'invoice.paid' && event.data.object.billing_reason === 'subscription_cycle' && result?.stripeSubscriptionId) {
void deps.productEventService?.track({
userId: result.userId,
feature: 'billing',
action: 'subscription_renewed',
status: 'succeeded',
source: 'stripe.webhook',
metadata: {
amount_paid: result.amountPaid ?? null,
currency: result.currency ?? null,
stripe_price_id: result.stripePriceId ?? null,
},
})
}
break
}
}
@@ -128,6 +166,14 @@ export function createWebhookOperation(deps: WebhookOperationDeps) {
}
}
function subscriptionActionForWebhookEvent(eventType: Stripe.Event.Type): ProductAction | null {
if (eventType === 'customer.subscription.created')
return 'subscription_started'
if (eventType === 'customer.subscription.deleted')
return 'subscription_cancelled'
return null
}
async function handleCheckoutSessionCompleted(
stripeEventId: string,
session: Stripe.Checkout.Session,
@@ -239,11 +285,11 @@ async function handleCustomerEvent(
async function handleSubscriptionEvent(
subscription: Stripe.Subscription,
stripeService: StripeService,
) {
): Promise<StripeSubscriptionEventContext | null> {
const stripeCustomerId = typeof subscription.customer === 'string' ? subscription.customer : subscription.customer.id
const customer = await stripeService.getCustomerByStripeId(stripeCustomerId)
if (!customer)
return
return null
// In newer Stripe API, period info is on subscription items.
const firstItem = subscription.items.data[0]
@@ -260,19 +306,27 @@ async function handleSubscriptionEvent(
endedAt: subscription.ended_at ? new Date(subscription.ended_at * 1000) : null,
metadata: subscription.metadata ? JSON.stringify(subscription.metadata) : null,
})
return {
userId: customer.userId,
stripeCustomerId,
stripeSubscriptionId: subscription.id,
stripePriceId: firstItem?.price?.id,
subscriptionStatus: subscription.status,
}
}
async function handleInvoiceEvent(
invoice: Stripe.Invoice,
stripeService: StripeService,
) {
): Promise<StripeSubscriptionEventContext | null> {
const stripeCustomerId = typeof invoice.customer === 'string' ? invoice.customer : invoice.customer?.id
if (!stripeCustomerId)
return
return null
const customer = await stripeService.getCustomerByStripeId(stripeCustomerId)
if (!customer)
return
return null
// In newer Stripe API, subscription is under parent.subscription_details.
const subDetails = invoice.parent?.subscription_details
@@ -300,4 +354,13 @@ async function handleInvoiceEvent(
// TODO: implement subscription-based flux crediting when subscriptions are enabled
if (invoice.status === 'paid' && invoice.amount_paid && subscriptionId)
logger.withFields({ userId: customer.userId, invoiceId: invoice.id, amountPaid: invoice.amount_paid }).warn('Subscription invoice paid but flux crediting for subscriptions is not yet implemented')
return {
userId: customer.userId,
stripeCustomerId,
stripeSubscriptionId: subscriptionId ?? '',
subscriptionStatus: invoice.status ?? undefined,
amountPaid: invoice.amount_paid,
currency: invoice.currency,
}
}
+118
View File
@@ -10,6 +10,7 @@ import { describe, expect, it, vi } from 'vitest'
import { createStripeRoutes, formatPrice } from '.'
import { ApiError } from '../../utils/error'
import { createWebhookOperation } from './operations/webhook'
// --- Mock helpers ---
@@ -452,5 +453,122 @@ describe('stripeRoutes', () => {
})
expect(res.status).toBe(503)
})
it('records subscription lifecycle product events from Stripe webhooks', async () => {
const subscriptionEvent = {
id: 'evt_sub_created',
type: 'customer.subscription.created',
data: {
object: {
id: 'sub_1',
customer: 'cus_1',
status: 'active',
items: {
data: [{
price: { id: 'price_1' },
current_period_start: 1_000,
current_period_end: 2_000,
}],
},
cancel_at_period_end: false,
canceled_at: null,
ended_at: null,
metadata: {},
},
},
}
const productEventService = { track: vi.fn() }
const stripeService = createMockStripeService({
getCustomerByStripeId: vi.fn(async () => ({ userId: 'user-1', stripeCustomerId: 'cus_1' })),
})
const webhook = createWebhookOperation({
stripe: {
webhooks: {
constructEvent: vi.fn(() => subscriptionEvent),
},
} as any,
webhookSecret: 'whsec_test',
fluxService: createMockFluxService(),
stripeService,
billingService: createMockBillingService(),
productEventService: productEventService as any,
})
await webhook({ signature: 'test_sig', body: '{}' })
expect(productEventService.track).toHaveBeenCalledWith({
userId: 'user-1',
feature: 'billing',
action: 'subscription_started',
status: 'succeeded',
source: 'stripe.webhook',
metadata: {
stripe_price_id: 'price_1',
stripe_subscription_status: 'active',
},
})
})
it('records subscription renewals only for subscription-cycle paid invoices', async () => {
const invoiceEvent = {
id: 'evt_invoice_paid',
type: 'invoice.paid',
data: {
object: {
id: 'inv_1',
customer: 'cus_1',
parent: {
subscription_details: {
subscription: 'sub_1',
},
},
billing_reason: 'subscription_cycle',
status: 'paid',
amount_due: 1_200,
amount_paid: 1_200,
currency: 'usd',
hosted_invoice_url: null,
invoice_pdf: null,
period_start: 1_000,
period_end: 2_000,
status_transitions: {
paid_at: 1_500,
},
metadata: {},
},
},
}
const productEventService = { track: vi.fn() }
const stripeService = createMockStripeService({
getCustomerByStripeId: vi.fn(async () => ({ userId: 'user-1', stripeCustomerId: 'cus_1' })),
})
const webhook = createWebhookOperation({
stripe: {
webhooks: {
constructEvent: vi.fn(() => invoiceEvent),
},
} as any,
webhookSecret: 'whsec_test',
fluxService: createMockFluxService(),
stripeService,
billingService: createMockBillingService(),
productEventService: productEventService as any,
})
await webhook({ signature: 'test_sig', body: '{}' })
expect(productEventService.track).toHaveBeenCalledWith({
userId: 'user-1',
feature: 'billing',
action: 'subscription_renewed',
status: 'succeeded',
source: 'stripe.webhook',
metadata: {
amount_paid: 1200,
currency: 'usd',
stripe_price_id: null,
},
})
})
})
})
@@ -29,6 +29,10 @@ export type ProductAction
| 'voice_pack_disabled'
| 'checkout_started'
| 'payment_completed'
| 'subscription_started'
| 'subscription_renewed'
| 'subscription_cancelled'
| 'topic_classified'
/**
* Product event fact written to AIRI's own Postgres analytics table.
@@ -15,7 +15,13 @@ const route = useRoute()
const router = useRouter()
const authStore = useAuthStore()
const { credits } = storeToRefs(authStore)
const { trackPricingViewed, trackPlanSelected, trackCheckoutStarted } = useAnalytics()
const {
trackCheckoutStarted,
trackPlanSelected,
trackPricingViewed,
trackQuotaLimitReached,
trackUpgradeClicked,
} = useAnalytics()
const fluxPurchaseDisabled = isFluxPurchaseDisabled()
@@ -232,7 +238,7 @@ async function fetchPackages() {
}
onMounted(async () => {
Promise.allSettled([authStore.updateCredits(), fetchStats(), fetchAuditHistory(), ...(fluxPurchaseDisabled ? [] : [fetchPackages()])])
await Promise.allSettled([authStore.updateCredits(), fetchStats(), fetchAuditHistory(), ...(fluxPurchaseDisabled ? [] : [fetchPackages()])])
// PostHog funnel step 1: pricing surface view. Today this is an in-app
// settings page (already-authenticated users); when we add a public
@@ -240,6 +246,14 @@ onMounted(async () => {
// same, so the funnel definition in PostHog doesn't need re-wiring.
if (!fluxPurchaseDisabled) {
trackPricingViewed('settings_flux', 'one_time')
if (credits.value <= 0) {
trackQuotaLimitReached({
limit_type: 'flux',
current_usage: credits.value,
limit_value: capacity.value > 0 ? capacity.value : undefined,
entry: 'pricing',
})
}
}
if (route.query.success === 'true') {
@@ -259,6 +273,11 @@ async function handleBuy(stripePriceId: string) {
// the Stripe webhook (server-side `payment_completed`); we deliberately
// don't send a formatted-string price from the SPA so funnels don't get
// poisoned by currency-formatting drift.
trackUpgradeClicked({
source_page: 'settings_flux',
current_plan: 'flux',
trigger: 'manual_topup',
})
trackPlanSelected(stripePriceId, { currency: selectedCurrency.value })
try {
const res = await client.api.v1.stripe.checkout.$post({ json: { stripePriceId, currency: selectedCurrency.value } })
@@ -1,4 +1,5 @@
<script setup lang="ts">
import type { ProviderMode } from '../../../../composables/use-analytics'
import type { ProviderMetadata } from '../../../../stores/providers'
import type {
OnboardingStep,
@@ -9,14 +10,14 @@ import type {
} from './types'
import { storeToRefs } from 'pinia'
import { computed, nextTick, ref } from 'vue'
import { computed, nextTick, onMounted, ref } from 'vue'
import StepModelSelection from './step-model-selection.vue'
import StepProviderConfiguration from './step-provider-configuration.vue'
import StepProviderSelection from './step-provider-selection.vue'
import StepWelcome from './step-welcome.vue'
import { capturePosthogEvent } from '../../../../stores/analytics/posthog'
import { useAnalytics } from '../../../../composables/use-analytics'
import { useConsciousnessStore } from '../../../../stores/modules/consciousness'
import { useProvidersStore } from '../../../../stores/providers'
@@ -34,6 +35,7 @@ const emit = defineEmits<Emits>()
const step = ref(0)
const direction = ref<'next' | 'previous'>('next')
const pendingProviderConfig = ref<ProviderConfigData | null>(null)
const { trackOnboardingCompleted, trackOnboardingStarted, trackOnboardingStepCompleted } = useAnalytics()
const providersStore = useProvidersStore()
const { providers, allChatProvidersMetadata } = storeToRefs(providersStore)
@@ -58,6 +60,12 @@ const selectedProvider = computed(() => {
return allChatProvidersMetadata.value.find(p => p.id === selectedProviderId.value) || null
})
const selectedProviderType = computed<ProviderMode>(() => {
if (!selectedProviderId.value)
return 'unknown'
return selectedProviderId.value.startsWith('official-provider') ? 'official' : 'custom'
})
// Reset validation state when provider changes
function selectProvider(provider: ProviderMetadata) {
selectedProviderId.value = provider.id
@@ -159,7 +167,12 @@ const isLastStep = computed(() => step.value === allSteps.value.length - 1)
const currentStepProps = computed(() => currentStep.value?.props?.() ?? {})
async function handleSave() {
capturePosthogEvent('onboarding_step_completed', { step: currentStep.value?.id ?? 'unknown' })
trackOnboardingStepCompleted(currentStep.value?.id ?? 'unknown')
trackOnboardingCompleted({
selected_provider_type: selectedProviderType.value,
selected_provider_id: selectedProviderId.value || undefined,
selected_use_case: 'unknown',
})
emit('configured')
}
@@ -182,7 +195,7 @@ async function navigateNext() {
return
}
capturePosthogEvent('onboarding_step_completed', { step: currentStep.value.id })
trackOnboardingStepCompleted(currentStep.value.id)
direction.value = 'next'
step.value++
}
@@ -197,6 +210,10 @@ async function navigatePrevious() {
direction.value = 'previous'
step.value--
}
onMounted(() => {
trackOnboardingStarted({ entry: 'app_start' })
})
</script>
<template>
@@ -385,7 +385,23 @@ describe('useAnalytics conversation product events', () => {
step: 'manual_chat_ping',
duration_ms: 18,
})
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(3, 'provider_config_failed', {
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(3, 'provider_config_completed', {
surface: 'web',
provider_id: 'official-provider',
provider_mode: 'official',
provider_type: 'official',
provider_name: 'official-provider',
entry_page: 'manual_chat_ping',
step: 'manual_chat_ping',
duration_ms: 18,
success: true,
})
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(4, 'official_provider_enabled', {
surface: 'web',
provider_name: 'official-provider',
entry: 'settings',
})
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(5, 'provider_config_failed', {
surface: 'web',
provider_id: 'openai-compatible',
provider_mode: 'custom',
@@ -395,6 +411,149 @@ describe('useAnalytics conversation product events', () => {
})
})
it('emits P0 activation, chat, quota, and feature events using canonical names', () => {
const analytics = useAnalytics()
analytics.trackSignupCompleted({
source: 'google',
locale: 'en',
utm_source: 'launch',
})
analytics.trackOnboardingStarted({
entry: 'app_start',
})
analytics.trackOnboardingCompleted({
selected_provider_type: 'official',
selected_provider_id: 'official-provider',
selected_use_case: 'role_chat',
})
analytics.trackChatStarted({
conversation_id: 'session-1',
provider_type: 'official',
provider_name: 'official-provider',
model: 'gpt-test',
entry: 'chat',
is_paid_user: true,
})
analytics.trackMessageSent({
conversation_id: 'session-1',
provider_type: 'official',
provider_name: 'official-provider',
model: 'gpt-test',
message_id: 'message-1',
message_index: 2,
message_length: 24,
has_attachment: false,
mode: 'text',
})
analytics.trackAssistantResponseCompleted({
conversation_id: 'session-1',
provider_type: 'official',
provider_name: 'official-provider',
model: 'gpt-test',
latency_ms: 350,
completion_length: 120,
})
analytics.trackChatFailed({
conversation_id: 'session-1',
provider_type: 'custom',
provider_name: 'openai-compatible',
model: 'custom',
failure_stage: 'llm_response',
error_code: 'provider_error',
})
analytics.trackQuotaLimitReached({
limit_type: 'flux',
current_usage: 0,
limit_value: 0,
entry: 'pricing',
})
analytics.trackUpgradeClicked({
source_page: 'settings_flux',
current_plan: 'flux',
trigger: 'manual_topup',
})
analytics.trackFeatureUsed({
feature_name: 'chat',
business_domain: 'conversation',
entry: 'chat',
success: true,
})
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(1, 'signup_completed', {
source: 'google',
locale: 'en',
utm_source: 'launch',
})
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(2, 'onboarding_started', {
surface: 'web',
entry: 'app_start',
})
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(3, 'onboarding_completed', {
surface: 'web',
selected_provider_type: 'official',
selected_provider_id: 'official-provider',
selected_use_case: 'role_chat',
})
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(4, 'chat_started', {
surface: 'web',
conversation_id: 'session-1',
provider_type: 'official',
provider_name: 'official-provider',
model: 'gpt-test',
entry: 'chat',
is_paid_user: true,
})
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(5, 'message_sent', {
surface: 'web',
conversation_id: 'session-1',
provider_type: 'official',
provider_name: 'official-provider',
model: 'gpt-test',
message_id: 'message-1',
message_index: 2,
message_length: 24,
has_attachment: false,
mode: 'text',
})
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(6, 'assistant_response_completed', {
surface: 'web',
conversation_id: 'session-1',
provider_type: 'official',
provider_name: 'official-provider',
model: 'gpt-test',
latency_ms: 350,
completion_length: 120,
})
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(7, 'chat_failed', {
surface: 'web',
conversation_id: 'session-1',
provider_type: 'custom',
provider_name: 'openai-compatible',
model: 'custom',
failure_stage: 'llm_response',
error_code: 'provider_error',
})
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(8, 'quota_limit_reached', {
limit_type: 'flux',
current_usage: 0,
limit_value: 0,
entry: 'pricing',
})
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(9, 'upgrade_clicked', {
source_page: 'settings_flux',
current_plan: 'flux',
trigger: 'manual_topup',
})
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(10, 'feature_used', {
surface: 'web',
feature_name: 'chat',
business_domain: 'conversation',
entry: 'chat',
success: true,
})
})
/**
* @example
* analytics.trackBugReportSubmitted({ source: 'app', category: 'update', severity: 'major', user_type: 'unknown', entrypoint: 'about_update_error', description_length_bucket: 'medium', include_triage_context: true, screenshot_attached: true })
@@ -30,6 +30,8 @@ export type FeedbackCategory = 'provider_config' | 'model_list' | 'chat_activati
export type FeedbackSeverity = 'blocker' | 'major' | 'minor' | 'suggestion'
export type FeedbackUserType = 'new_user' | 'paid_user' | 'overseas_user' | 'developer_user' | 'role_chat_user' | 'unknown'
export type FeedbackDescriptionLengthBucket = 'empty' | 'short' | 'medium' | 'long'
export type ProductAnalyticsEntry = 'app_start' | 'onboarding' | 'settings' | 'chat' | 'pricing' | 'quota_banner' | 'unknown'
export type MessageInputMode = 'text' | 'voice'
interface ChatActivationBaseProperties {
provider_mode: ProviderMode
@@ -63,6 +65,19 @@ interface FeedbackBaseProperties {
entrypoint: string
}
interface OnboardingProviderProperties {
selected_provider_type: ProviderMode
selected_provider_id?: string
selected_use_case?: string
}
interface ConversationBaseProperties {
conversation_id: string
provider_type: ProviderMode
provider_name: string
model: string
}
function getConversationAnalyticsSurface(): ConversationAnalyticsSurface {
if (isStageTamagotchi())
return 'electron'
@@ -180,6 +195,39 @@ export function useAnalytics() {
if (!canCapture())
return
posthog.capture('user_signed_up', { method })
posthog.capture('signup_completed', { source: method })
}
function trackSignupCompleted(properties: {
source: string
referrer?: string
country?: string
locale?: string
utm_source?: string
utm_medium?: string
utm_campaign?: string
}) {
if (!canCapture())
return
posthog.capture('signup_completed', properties)
}
function trackOnboardingStarted(properties: { entry: ProductAnalyticsEntry }) {
if (!canCapture())
return
posthog.capture('onboarding_started', {
...properties,
surface: getConversationAnalyticsSurface(),
})
}
function trackOnboardingCompleted(properties: OnboardingProviderProperties) {
if (!canCapture())
return
posthog.capture('onboarding_completed', {
...properties,
surface: getConversationAnalyticsSurface(),
})
}
/**
@@ -303,6 +351,57 @@ export function useAnalytics() {
})
}
function trackChatStarted(properties: ConversationBaseProperties & {
entry: ProductAnalyticsEntry
is_paid_user?: boolean
}) {
if (!canCapture())
return
posthog.capture('chat_started', {
...properties,
surface: getConversationAnalyticsSurface(),
})
}
function trackMessageSent(properties: ConversationBaseProperties & {
message_id?: string
message_index?: number
message_length?: number
has_attachment: boolean
mode: MessageInputMode
}) {
if (!canCapture())
return
posthog.capture('message_sent', {
...properties,
surface: getConversationAnalyticsSurface(),
})
}
function trackAssistantResponseCompleted(properties: ConversationBaseProperties & {
latency_ms?: number
completion_length?: number
}) {
if (!canCapture())
return
posthog.capture('assistant_response_completed', {
...properties,
surface: getConversationAnalyticsSurface(),
})
}
function trackChatFailed(properties: ConversationBaseProperties & {
failure_stage: ChatActivationFailureStage
error_code: string
}) {
if (!canCapture())
return
posthog.capture('chat_failed', {
...properties,
surface: getConversationAnalyticsSurface(),
})
}
function trackModelListLoaded(properties: {
provider_id: string
provider_mode: ProviderMode
@@ -347,6 +446,16 @@ export function useAnalytics() {
...properties,
surface: getConversationAnalyticsSurface(),
})
trackProviderConfigCompleted({
...properties,
success: true,
})
if (properties.provider_mode === 'official') {
trackOfficialProviderEnabled({
provider_name: properties.provider_id,
entry: properties.step === 'onboarding_validate' ? 'onboarding' : 'settings',
})
}
}
function trackProviderConfigFailed(properties: ProviderConfigBaseProperties & {
@@ -361,6 +470,34 @@ export function useAnalytics() {
})
}
function trackProviderConfigCompleted(properties: ProviderConfigBaseProperties & {
duration_ms: number
success: boolean
error_code?: string
}) {
if (!canCapture())
return
posthog.capture('provider_config_completed', {
...properties,
provider_type: properties.provider_mode,
provider_name: properties.provider_id,
entry_page: properties.step,
surface: getConversationAnalyticsSurface(),
})
}
function trackOfficialProviderEnabled(properties: {
provider_name: string
entry: 'onboarding' | 'settings' | 'chat'
}) {
if (!canCapture())
return
posthog.capture('official_provider_enabled', {
...properties,
surface: getConversationAnalyticsSurface(),
})
}
// ─── Conversation action events ─────────────────────────────────────
function trackTtsStopClicked(properties: { reason: 'manual-chat' }) {
@@ -643,6 +780,41 @@ export function useAnalytics() {
posthog.capture('flux_topup_clicked', properties)
}
function trackQuotaLimitReached(properties: {
limit_type: 'flux' | 'rate_limit' | 'subscription'
current_usage: number
limit_value?: number
entry: ProductAnalyticsEntry
}) {
if (!canCapture())
return
posthog.capture('quota_limit_reached', properties)
}
function trackUpgradeClicked(properties: {
source_page: string
current_plan?: string
trigger: 'quota_limit' | 'pricing_page' | 'manual_topup' | 'feature_gate'
}) {
if (!canCapture())
return
posthog.capture('upgrade_clicked', properties)
}
function trackFeatureUsed(properties: {
feature_name: string
business_domain: string
entry: ProductAnalyticsEntry
success: boolean
}) {
if (!canCapture())
return
posthog.capture('feature_used', {
...properties,
surface: getConversationAnalyticsSurface(),
})
}
// ─── Voice clone (custom TTS voice) ──────────────────────────────────
function trackVoiceCloneCreated(properties: { provider: string }) {
@@ -667,6 +839,9 @@ export function useAnalytics() {
trackPlanSelected,
trackCheckoutStarted,
trackSignup,
trackSignupCompleted,
trackOnboardingStarted,
trackOnboardingCompleted,
trackFirstModelSelected,
trackCharacterCreated,
trackVoiceModeActivated,
@@ -677,7 +852,11 @@ export function useAnalytics() {
trackLlmRequestStarted,
trackLlmFirstToken,
trackAssistantResponseRendered,
trackAssistantResponseCompleted,
trackMessageRound,
trackChatStarted,
trackMessageSent,
trackChatFailed,
trackChatActivationStarted,
trackChatActivationSucceeded,
trackChatActivationFailed,
@@ -686,6 +865,8 @@ export function useAnalytics() {
trackProviderConfigStarted,
trackProviderConfigSucceeded,
trackProviderConfigFailed,
trackProviderConfigCompleted,
trackOfficialProviderEnabled,
trackTtsStopClicked,
trackChatSessionSelected,
trackChatMessageDeleted,
@@ -726,6 +907,9 @@ export function useAnalytics() {
trackFluxLowWarningShown,
trackFluxTopupClicked,
trackQuotaLimitReached,
trackUpgradeClicked,
trackFeatureUsed,
trackVoiceCloneCreated,
trackDeviceChannelConnected,
}
+70 -17
View File
@@ -48,14 +48,19 @@ export const useChatOrchestratorStore = defineStore('chat-orchestrator', () => {
const llmToolsetPromptsStore = useLlmToolsetPromptsStore()
const consciousnessStore = useConsciousnessStore()
const artistryAutonomousStore = useAutonomousArtistryStore()
const { activeProvider } = storeToRefs(consciousnessStore)
const { activeModel, activeProvider } = storeToRefs(consciousnessStore)
const {
trackFirstMessage,
trackChatFailed,
trackChatStarted,
trackMessageSendStarted,
trackMessageSent,
trackLlmRequestStarted,
trackLlmFirstToken,
trackAssistantResponseRendered,
trackAssistantResponseCompleted,
trackMessageRound,
trackFeatureUsed,
trackChatActivationStarted,
trackChatActivationSucceeded,
trackChatActivationFailed,
@@ -144,6 +149,8 @@ export const useChatOrchestratorStore = defineStore('chat-orchestrator', () => {
return providerId.startsWith('official-provider') ? 'official' : 'custom'
}
let lastSendSource: 'text' | 'voice' = 'text'
const runtime = createChatOrchestratorRuntime({
session: {
ensureSession: sessionId => chatSession.ensureSession(sessionId),
@@ -177,10 +184,20 @@ export const useChatOrchestratorStore = defineStore('chat-orchestrator', () => {
onStateChange: syncRuntimeState,
onSendSettled: settleOwnedActiveTurnSpan,
onTrackFirstMessage: trackFirstMessage,
onMessageSendStarted: ({ source, model }) => trackMessageSendStarted({
source,
model,
}),
onMessageSendStarted: ({ source, model }) => {
lastSendSource = source
trackMessageSendStarted({
source,
model,
})
trackChatStarted({
conversation_id: activeSessionId.value || 'unknown',
provider_type: providerMode(activeProvider.value),
provider_name: activeProvider.value || 'unknown',
model: model || 'unknown',
entry: 'chat',
})
},
onLlmRequestStarted: ({ model, provider, hasVoice }) => trackLlmRequestStarted({
model,
provider,
@@ -190,10 +207,19 @@ export const useChatOrchestratorStore = defineStore('chat-orchestrator', () => {
model,
ttfb_ms: ttfbMs,
}),
onAssistantResponseRendered: ({ model, latencyMs }) => trackAssistantResponseRendered({
model,
latency_ms: latencyMs,
}),
onAssistantResponseRendered: ({ model, latencyMs }) => {
trackAssistantResponseRendered({
model,
latency_ms: latencyMs,
})
trackAssistantResponseCompleted({
conversation_id: activeSessionId.value || 'unknown',
provider_type: providerMode(activeProvider.value),
provider_name: activeProvider.value || 'unknown',
model: model || 'unknown',
latency_ms: latencyMs,
})
},
onMessageRound: ({ durationMs, hasVoice, model }) => trackMessageRound({
duration_ms: durationMs,
has_voice: hasVoice,
@@ -212,17 +238,44 @@ export const useChatOrchestratorStore = defineStore('chat-orchestrator', () => {
time_to_first_message_ms: durationMs,
source,
}),
onChatActivationFailed: ({ model, provider, errorCode, failureStage, source }) => trackChatActivationFailed({
provider_mode: providerMode(provider),
provider_id: provider || 'unknown',
model_id: model || 'unknown',
error_code: errorCode,
failure_stage: failureStage,
source,
}),
onChatActivationFailed: ({ model, provider, errorCode, failureStage, source }) => {
trackChatActivationFailed({
provider_mode: providerMode(provider),
provider_id: provider || 'unknown',
model_id: model || 'unknown',
error_code: errorCode,
failure_stage: failureStage,
source,
})
trackChatFailed({
conversation_id: activeSessionId.value || 'unknown',
provider_type: providerMode(provider),
provider_name: provider || 'unknown',
model: model || 'unknown',
failure_stage: failureStage,
error_code: errorCode,
})
},
onLifecycle: record => contextObservability.recordLifecycle(record),
onPromptProjection: payload => contextObservability.capturePromptProjection(payload),
onUserMessageAppended: ({ sessionId, message, messageText }) => {
trackMessageSent({
conversation_id: sessionId,
provider_type: providerMode(activeProvider.value),
provider_name: activeProvider.value || 'unknown',
model: activeModel.value || 'unknown',
message_id: message.id,
message_index: chatSession.getSessionMessages(sessionId).length,
message_length: messageText.length,
has_attachment: false,
mode: lastSendSource,
})
trackFeatureUsed({
feature_name: 'chat',
business_domain: 'conversation',
entry: 'chat',
success: true,
})
if (isCloudSyncableMessage(message)) {
void chatSession.pushMessageToCloud(sessionId, {
id: message.id,