feat(analytics): add p0 operations events
This commit is contained in:
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user