refactor(analytics): replace PostHog with OpenPanel (#2480)
This commit is contained in:
@@ -51,7 +51,6 @@
|
||||
"nanoid": "catalog:",
|
||||
"node-vibrant": "catalog:",
|
||||
"pinia": "catalog:",
|
||||
"posthog-js": "catalog:",
|
||||
"reka-ui": "catalog:",
|
||||
"unspeech": "catalog:xsai",
|
||||
"vue": "catalog:",
|
||||
|
||||
@@ -47,7 +47,6 @@
|
||||
"nanoid": "catalog:",
|
||||
"node-vibrant": "catalog:",
|
||||
"pinia": "catalog:",
|
||||
"posthog-js": "catalog:",
|
||||
"reka-ui": "catalog:",
|
||||
"splitpanes": "catalog:",
|
||||
"unspeech": "catalog:xsai",
|
||||
|
||||
@@ -282,10 +282,10 @@ onMounted(async () => {
|
||||
|
||||
await creditsRefresh.catch(() => undefined)
|
||||
|
||||
// PostHog funnel step 1: pricing surface view. Today this is an in-app
|
||||
// OpenPanel funnel step 1: pricing surface view. Today this is an in-app
|
||||
// settings page (already-authenticated users); when we add a public
|
||||
// pricing landing page the entry-surface label changes but the event stays the
|
||||
// same, so the funnel definition in PostHog doesn't need re-wiring.
|
||||
// same, so the funnel definition in OpenPanel doesn't need re-wiring.
|
||||
if (!fluxPurchaseDisabled) {
|
||||
trackPaywallSeen({
|
||||
entry_surface: 'settings_flux',
|
||||
@@ -308,7 +308,7 @@ async function handleBuy(stripePriceId: string) {
|
||||
loadingPriceId.value = stripePriceId
|
||||
checkoutReturnMessageActive.value = false
|
||||
message.value = null
|
||||
// PostHog funnel step 2: user picked a plan. price_minor_unit lives on
|
||||
// OpenPanel funnel step 2: user picked a plan. price_minor_unit lives on
|
||||
// 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.
|
||||
@@ -330,9 +330,8 @@ async function handleBuy(stripePriceId: string) {
|
||||
}
|
||||
const data = await res.json()
|
||||
if (data.url) {
|
||||
// PostHog funnel step 3: about to redirect to Stripe. Capture before
|
||||
// the page nav so the event is sent (PostHog's beforeunload handler
|
||||
// would otherwise race the navigation).
|
||||
// Start capture before redirecting to Stripe so fetch keepalive can
|
||||
// finish delivery after the page unloads.
|
||||
trackCheckoutStarted(stripePriceId, {
|
||||
currency: selectedCurrency.value,
|
||||
entry_surface: 'settings_flux',
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./auth": "./src/auth/index.ts",
|
||||
"./analytics/posthog": "./src/analytics/posthog.ts",
|
||||
"./analytics/openpanel": "./src/analytics/openpanel.ts",
|
||||
"./beat-sync": "./src/beat-sync/index.ts",
|
||||
"./global-shortcut": "./src/global-shortcut/index.ts",
|
||||
"./godot-stage": "./src/godot-stage/index.ts",
|
||||
@@ -37,7 +37,6 @@
|
||||
"@vueuse/core": "catalog:",
|
||||
"gpuu": "catalog:",
|
||||
"pinia": "catalog:",
|
||||
"posthog-js": "catalog:",
|
||||
"valibot": "catalog:",
|
||||
"vue": "catalog:"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
/** Public write configuration shared by AIRI client surfaces. Never add a client secret here. */
|
||||
export const OPENPANEL_CONFIG = {
|
||||
apiUrl: 'https://analytics.airi.build/api',
|
||||
clientId: 'f930f223-4a46-4064-b919-1a5465dbc029',
|
||||
} as const
|
||||
@@ -1,25 +0,0 @@
|
||||
import type { PostHogConfig } from 'posthog-js'
|
||||
|
||||
function isEnvFlagEnabled(value: string | undefined): boolean {
|
||||
if (value == null)
|
||||
return false
|
||||
|
||||
return /^(?:1|true|t|yes|y|on)$/i.test(value.trim())
|
||||
}
|
||||
|
||||
/** Whether client analytics is enabled for the current Vite build. */
|
||||
export const POSTHOG_ENABLED = isEnvFlagEnabled(import.meta.env.VITE_ENABLE_POSTHOG)
|
||||
|
||||
/** The shared PostHog project key used by every AIRI client surface. */
|
||||
export const POSTHOG_PROJECT_KEY
|
||||
= import.meta.env.VITE_POSTHOG_PROJECT_KEY
|
||||
?? 'phc_pzjziJjrVZpa9SqnQqq0QEKvkmuCPH7GDTA6TbRTEf9' // cspell:disable-line
|
||||
|
||||
/** Shared PostHog defaults for AIRI single-page applications. */
|
||||
export const DEFAULT_POSTHOG_CONFIG = {
|
||||
api_host: 'https://t.airi.build',
|
||||
// This preset captures page views on history changes. PostHog then also
|
||||
// captures page leaves, which keeps route-level dwell time measurable.
|
||||
defaults: '2025-05-24',
|
||||
person_profiles: 'identified_only',
|
||||
} as const satisfies Partial<PostHogConfig>
|
||||
@@ -75,6 +75,7 @@
|
||||
"@huggingface/transformers": "catalog:",
|
||||
"@moeru/eventa": "catalog:",
|
||||
"@moeru/std": "catalog:",
|
||||
"@openpanel/web": "catalog:",
|
||||
"@opentelemetry/api": "catalog:",
|
||||
"@opentelemetry/core": "catalog:",
|
||||
"@opentelemetry/sdk-trace-base": "catalog:",
|
||||
@@ -150,7 +151,6 @@
|
||||
"ofetch": "catalog:",
|
||||
"pinia": "catalog:",
|
||||
"pinia-plugin-synced": "catalog:",
|
||||
"posthog-js": "catalog:",
|
||||
"rehype-katex": "catalog:",
|
||||
"rehype-parse": "catalog:",
|
||||
"rehype-stringify": "catalog:",
|
||||
|
||||
@@ -9,7 +9,7 @@ const analyticsMocks = vi.hoisted(() => ({
|
||||
isStageTamagotchiMock: vi.fn(() => false),
|
||||
isAnalyticsAvailableInBuildMock: vi.fn(() => true),
|
||||
recordFirstMessageMock: vi.fn(() => true),
|
||||
posthogCaptureMock: vi.fn(),
|
||||
captureMock: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@proj-airi/stage-shared', () => ({
|
||||
@@ -29,14 +29,14 @@ vi.mock('vue-i18n', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('../libs/product-signals', () => ({
|
||||
captureAnalyticsEvent: analyticsMocks.posthogCaptureMock,
|
||||
captureAnalyticsEvent: analyticsMocks.captureMock,
|
||||
enableAnalytics: analyticsMocks.ensureAnalyticsInitializedMock,
|
||||
getAnalytics: () => ({
|
||||
emit: (event: { name: string }, payload: object, options?: object) => {
|
||||
if (options)
|
||||
return analyticsMocks.posthogCaptureMock(event.name, payload, options)
|
||||
return analyticsMocks.captureMock(event.name, payload, options)
|
||||
|
||||
return analyticsMocks.posthogCaptureMock(event.name, payload)
|
||||
return analyticsMocks.captureMock(event.name, payload)
|
||||
},
|
||||
recordFirstMessage: analyticsMocks.recordFirstMessageMock,
|
||||
}),
|
||||
@@ -58,7 +58,7 @@ vi.mock('../stores/settings/general', () => ({
|
||||
|
||||
describe('useAnalytics conversation product events', () => {
|
||||
beforeEach(() => {
|
||||
analyticsMocks.posthogCaptureMock.mockClear()
|
||||
analyticsMocks.captureMock.mockClear()
|
||||
analyticsMocks.recordFirstMessageMock.mockClear()
|
||||
analyticsMocks.ensureAnalyticsInitializedMock.mockClear()
|
||||
analyticsMocks.isStageCapacitorMock.mockReset()
|
||||
@@ -75,7 +75,7 @@ describe('useAnalytics conversation product events', () => {
|
||||
reason: 'manual-chat',
|
||||
})
|
||||
|
||||
expect(analyticsMocks.posthogCaptureMock).toHaveBeenCalledWith('tts_stop_clicked', {
|
||||
expect(analyticsMocks.captureMock).toHaveBeenCalledWith('tts_stop_clicked', {
|
||||
app_surface: 'web',
|
||||
reason: 'manual-chat',
|
||||
})
|
||||
@@ -89,82 +89,13 @@ describe('useAnalytics conversation product events', () => {
|
||||
was_speaking: true,
|
||||
})
|
||||
|
||||
expect(analyticsMocks.posthogCaptureMock).toHaveBeenCalledWith('speech_mute_toggled', {
|
||||
expect(analyticsMocks.captureMock).toHaveBeenCalledWith('speech_mute_toggled', {
|
||||
app_surface: 'web',
|
||||
muted: true,
|
||||
was_speaking: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('captures custom-provider token usage without prompt or response content', () => {
|
||||
const analytics = useAnalytics()
|
||||
|
||||
analytics.trackAiGeneration({
|
||||
conversation_id: 'session-1',
|
||||
round_id: 'round-1',
|
||||
provider_type: 'custom',
|
||||
provider_id: 'openai-compatible',
|
||||
model_id: 'custom-model',
|
||||
usage_source: 'reported',
|
||||
input_tokens: 12,
|
||||
output_tokens: 8,
|
||||
})
|
||||
|
||||
expect(analyticsMocks.posthogCaptureMock).toHaveBeenCalledWith('$ai_generation', {
|
||||
$ai_trace_id: 'session-1',
|
||||
$ai_session_id: 'session-1',
|
||||
$ai_span_id: 'round-1',
|
||||
$ai_model: 'custom-model',
|
||||
$ai_provider: 'openai-compatible',
|
||||
$ai_input_tokens: 12,
|
||||
$ai_output_tokens: 8,
|
||||
$ai_total_tokens: 20,
|
||||
$insert_id: 'ai-generation:round-1',
|
||||
app_surface: 'web',
|
||||
capture_surface: 'client',
|
||||
conversation_id: 'session-1',
|
||||
conversation_id_source: 'client_runtime',
|
||||
round_id: 'round-1',
|
||||
provider_type: 'custom',
|
||||
usage_source: 'reported',
|
||||
token_usage_available: true,
|
||||
cost_usd_source: 'unavailable',
|
||||
cost_usd_known: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('records unavailable custom-provider usage without inventing token or cost fields', () => {
|
||||
const analytics = useAnalytics()
|
||||
|
||||
analytics.trackAiGeneration({
|
||||
conversation_id: 'session-1',
|
||||
round_id: 'round-2',
|
||||
provider_type: 'custom',
|
||||
provider_id: 'ollama',
|
||||
model_id: 'local-model',
|
||||
usage_source: 'unavailable',
|
||||
})
|
||||
|
||||
expect(analyticsMocks.posthogCaptureMock).toHaveBeenCalledWith('$ai_generation', {
|
||||
$ai_trace_id: 'session-1',
|
||||
$ai_session_id: 'session-1',
|
||||
$ai_span_id: 'round-2',
|
||||
$ai_model: 'local-model',
|
||||
$ai_provider: 'ollama',
|
||||
$insert_id: 'ai-generation:round-2',
|
||||
app_surface: 'web',
|
||||
capture_surface: 'client',
|
||||
conversation_id: 'session-1',
|
||||
conversation_id_source: 'client_runtime',
|
||||
round_id: 'round-2',
|
||||
provider_type: 'custom',
|
||||
usage_source: 'unavailable',
|
||||
token_usage_available: false,
|
||||
cost_usd_source: 'unavailable',
|
||||
cost_usd_known: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('infers the mobile surface for capacitor conversation actions', () => {
|
||||
analyticsMocks.isStageCapacitorMock.mockReturnValue(true)
|
||||
const analytics = useAnalytics()
|
||||
@@ -175,7 +106,7 @@ describe('useAnalytics conversation product events', () => {
|
||||
cloud_synced: true,
|
||||
})
|
||||
|
||||
expect(analyticsMocks.posthogCaptureMock).toHaveBeenCalledWith('chat_session_selected', {
|
||||
expect(analyticsMocks.captureMock).toHaveBeenCalledWith('chat_session_selected', {
|
||||
app_surface: 'mobile',
|
||||
source: 'sessions_drawer',
|
||||
message_count: 4,
|
||||
@@ -199,17 +130,17 @@ describe('useAnalytics conversation product events', () => {
|
||||
source: 'history',
|
||||
})
|
||||
|
||||
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(1, 'chat_message_deleted', {
|
||||
expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(1, 'chat_message_deleted', {
|
||||
app_surface: 'electron',
|
||||
source: 'history',
|
||||
message_role: 'assistant',
|
||||
})
|
||||
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(2, 'chat_messages_cleared', {
|
||||
expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(2, 'chat_messages_cleared', {
|
||||
app_surface: 'electron',
|
||||
source: 'chat_controls',
|
||||
message_count: 3,
|
||||
})
|
||||
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(3, 'chat_message_retried', {
|
||||
expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(3, 'chat_message_retried', {
|
||||
app_surface: 'electron',
|
||||
source: 'history',
|
||||
})
|
||||
@@ -218,7 +149,7 @@ describe('useAnalytics conversation product events', () => {
|
||||
/**
|
||||
* @example
|
||||
* analytics.trackVoiceSelected({ tts_provider_id: 'official-provider', tts_model_id: 'stepfun/tts', voice_id: 'voice-1', voice_type: 'official_selected', source: 'settings' })
|
||||
* expect(posthog.capture).toHaveBeenCalledWith('voice_selected', expect.objectContaining({ voice_id: 'voice-1' }))
|
||||
* expect(captureAnalyticsEvent).toHaveBeenCalledWith('voice_selected', expect.objectContaining({ voice_id: 'voice-1' }))
|
||||
*/
|
||||
it('emits TTS voice selection events without losing provider context', () => {
|
||||
const analytics = useAnalytics()
|
||||
@@ -250,7 +181,7 @@ describe('useAnalytics conversation product events', () => {
|
||||
source: 'settings',
|
||||
})
|
||||
|
||||
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(1, 'tts_provider_selected', {
|
||||
expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(1, 'tts_provider_selected', {
|
||||
app_surface: 'web',
|
||||
tts_provider_id: 'official-provider',
|
||||
tts_model_id: 'stepfun/tts',
|
||||
@@ -258,7 +189,7 @@ describe('useAnalytics conversation product events', () => {
|
||||
trigger_method: 'selection',
|
||||
trigger_type: 'user_action',
|
||||
})
|
||||
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(2, 'voice_selected', {
|
||||
expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(2, 'voice_selected', {
|
||||
app_surface: 'web',
|
||||
tts_provider_id: 'official-provider',
|
||||
tts_model_id: 'stepfun/tts',
|
||||
@@ -266,7 +197,7 @@ describe('useAnalytics conversation product events', () => {
|
||||
voice_type: 'official_selected',
|
||||
source: 'settings',
|
||||
})
|
||||
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(3, 'voice_preview_played', {
|
||||
expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(3, 'voice_preview_played', {
|
||||
app_surface: 'web',
|
||||
tts_provider_id: 'official-provider',
|
||||
tts_model_id: 'stepfun/tts',
|
||||
@@ -274,7 +205,7 @@ describe('useAnalytics conversation product events', () => {
|
||||
voice_type: 'official_selected',
|
||||
source: 'manual_preview',
|
||||
})
|
||||
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(4, 'voice_pack_bound', {
|
||||
expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(4, 'voice_pack_bound', {
|
||||
app_surface: 'web',
|
||||
tts_provider_id: 'official-provider',
|
||||
tts_model_id: 'stepfun/tts',
|
||||
@@ -287,7 +218,7 @@ describe('useAnalytics conversation product events', () => {
|
||||
/**
|
||||
* @example
|
||||
* analytics.trackOfficialTtsExposed({ source: 'post_first_chat', tts_provider_id: 'official-provider-speech', tts_model_id: 'stepfun/tts' })
|
||||
* expect(posthog.capture).toHaveBeenCalledWith('official_tts_exposed', expect.objectContaining({ source: 'post_first_chat' }))
|
||||
* expect(captureAnalyticsEvent).toHaveBeenCalledWith('official_tts_exposed', expect.objectContaining({ source: 'post_first_chat' }))
|
||||
*/
|
||||
it('emits official TTS activation funnel events', () => {
|
||||
const analytics = useAnalytics()
|
||||
@@ -312,13 +243,13 @@ describe('useAnalytics conversation product events', () => {
|
||||
source: 'manual_preview',
|
||||
duration_ms: 320,
|
||||
})
|
||||
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(1, 'official_tts_exposed', {
|
||||
expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(1, 'official_tts_exposed', {
|
||||
app_surface: 'web',
|
||||
tts_provider_id: 'official-provider-speech',
|
||||
tts_model_id: 'stepfun/tts',
|
||||
source: 'post_first_chat',
|
||||
})
|
||||
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(2, 'official_tts_preview_started', {
|
||||
expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(2, 'official_tts_preview_started', {
|
||||
app_surface: 'web',
|
||||
tts_provider_id: 'official-provider-speech',
|
||||
tts_model_id: 'stepfun/tts',
|
||||
@@ -326,7 +257,7 @@ describe('useAnalytics conversation product events', () => {
|
||||
voice_type: 'official_selected',
|
||||
source: 'manual_preview',
|
||||
})
|
||||
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(3, 'official_tts_preview_succeeded', {
|
||||
expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(3, 'official_tts_preview_succeeded', {
|
||||
app_surface: 'web',
|
||||
tts_provider_id: 'official-provider-speech',
|
||||
tts_model_id: 'stepfun/tts',
|
||||
@@ -346,7 +277,7 @@ describe('useAnalytics conversation product events', () => {
|
||||
flux_balance_bucket: '1_100',
|
||||
})
|
||||
|
||||
expect(analyticsMocks.posthogCaptureMock).toHaveBeenCalledWith('paywall_seen', {
|
||||
expect(analyticsMocks.captureMock).toHaveBeenCalledWith('paywall_seen', {
|
||||
app_surface: 'web',
|
||||
entry_surface: 'settings_flux',
|
||||
reason: 'manual_topup',
|
||||
@@ -367,16 +298,16 @@ describe('useAnalytics conversation product events', () => {
|
||||
entry_surface: 'settings_flux',
|
||||
})
|
||||
|
||||
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(1, 'pricing_page_viewed', {
|
||||
expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(1, 'pricing_page_viewed', {
|
||||
entry_surface: 'settings_flux',
|
||||
plan_period: 'one_time',
|
||||
})
|
||||
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(2, 'plan_selected', {
|
||||
expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(2, 'plan_selected', {
|
||||
currency: 'USD',
|
||||
entry_surface: 'settings_flux',
|
||||
plan_id: 'price-1',
|
||||
})
|
||||
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(3, 'checkout_started', {
|
||||
expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(3, 'checkout_started', {
|
||||
currency: 'USD',
|
||||
entry_surface: 'settings_flux',
|
||||
plan_id: 'price-1',
|
||||
@@ -386,7 +317,7 @@ describe('useAnalytics conversation product events', () => {
|
||||
/**
|
||||
* @example
|
||||
* analytics.trackMicrophonePermissionDenied({ stt_provider_id: 'browser-web-speech-api' })
|
||||
* expect(posthog.capture).toHaveBeenCalledWith('microphone_permission_denied', expect.objectContaining({ app_surface: 'web' }))
|
||||
* expect(captureAnalyticsEvent).toHaveBeenCalledWith('microphone_permission_denied', expect.objectContaining({ app_surface: 'web' }))
|
||||
*/
|
||||
it('emits one voice action and only user-relevant outcome events', () => {
|
||||
const analytics = useAnalytics()
|
||||
@@ -403,33 +334,33 @@ describe('useAnalytics conversation product events', () => {
|
||||
duration_ms: 420,
|
||||
})
|
||||
|
||||
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(1, 'voice_input_started', {
|
||||
expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(1, 'voice_input_started', {
|
||||
app_surface: 'web',
|
||||
stt_provider_id: 'browser-web-speech-api',
|
||||
trigger_method: 'voice',
|
||||
trigger_type: 'user_action',
|
||||
})
|
||||
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(2, 'microphone_permission_denied', {
|
||||
expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(2, 'microphone_permission_denied', {
|
||||
app_surface: 'web',
|
||||
stt_provider_id: 'browser-web-speech-api',
|
||||
error_code: 'permission_denied',
|
||||
trigger_method: 'voice',
|
||||
trigger_type: 'user_flow_result',
|
||||
})
|
||||
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(3, 'voice_input_cancelled', {
|
||||
expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(3, 'voice_input_cancelled', {
|
||||
app_surface: 'web',
|
||||
stt_provider_id: 'browser-web-speech-api',
|
||||
duration_ms: 420,
|
||||
trigger_method: 'voice',
|
||||
trigger_type: 'user_flow_result',
|
||||
})
|
||||
expect(analyticsMocks.posthogCaptureMock).toHaveBeenCalledTimes(3)
|
||||
expect(analyticsMocks.captureMock).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
/**
|
||||
* @example
|
||||
* analytics.trackProviderConnectionTestCompleted({ provider_id: 'openai-compatible', provider_mode: 'custom', success: false, error_code: 'validation_failed', duration_ms: 32 })
|
||||
* expect(posthog.capture).toHaveBeenCalledWith('provider_connection_test_completed', expect.objectContaining({ error_code: 'validation_failed' }))
|
||||
* expect(captureAnalyticsEvent).toHaveBeenCalledWith('provider_connection_test_completed', expect.objectContaining({ error_code: 'validation_failed' }))
|
||||
*/
|
||||
it('emits one manual provider test action and one result', () => {
|
||||
const analytics = useAnalytics()
|
||||
@@ -445,14 +376,14 @@ describe('useAnalytics conversation product events', () => {
|
||||
success: true,
|
||||
})
|
||||
|
||||
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(1, 'provider_connection_test_started', {
|
||||
expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(1, 'provider_connection_test_started', {
|
||||
app_surface: 'web',
|
||||
provider_id: 'official-provider',
|
||||
provider_mode: 'official',
|
||||
trigger_method: 'button',
|
||||
trigger_type: 'user_action',
|
||||
})
|
||||
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(2, 'provider_connection_test_completed', {
|
||||
expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(2, 'provider_connection_test_completed', {
|
||||
app_surface: 'web',
|
||||
provider_id: 'official-provider',
|
||||
provider_mode: 'official',
|
||||
@@ -461,7 +392,7 @@ describe('useAnalytics conversation product events', () => {
|
||||
trigger_method: 'button',
|
||||
trigger_type: 'user_flow_result',
|
||||
})
|
||||
expect(analyticsMocks.posthogCaptureMock).toHaveBeenCalledTimes(2)
|
||||
expect(analyticsMocks.captureMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('marks provider and model selections as explicit user actions', () => {
|
||||
@@ -470,14 +401,14 @@ describe('useAnalytics conversation product events', () => {
|
||||
analytics.trackProviderClick('official-provider', 'consciousness')
|
||||
analytics.trackModelSwitched('none', 'model-b')
|
||||
|
||||
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(1, 'provider_card_clicked', {
|
||||
expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(1, 'provider_card_clicked', {
|
||||
app_surface: 'web',
|
||||
provider_id: 'official-provider',
|
||||
module: 'consciousness',
|
||||
trigger_method: 'provider_card',
|
||||
trigger_type: 'user_action',
|
||||
})
|
||||
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(2, 'model_switched', {
|
||||
expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(2, 'model_switched', {
|
||||
app_surface: 'web',
|
||||
from_model: 'none',
|
||||
to_model: 'model-b',
|
||||
@@ -529,17 +460,17 @@ describe('useAnalytics conversation product events', () => {
|
||||
success: true,
|
||||
})
|
||||
|
||||
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(1, 'onboarding_started', {
|
||||
expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(1, 'onboarding_started', {
|
||||
app_surface: 'web',
|
||||
entry: 'app_start',
|
||||
})
|
||||
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(2, 'onboarding_completed', {
|
||||
expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(2, 'onboarding_completed', {
|
||||
app_surface: 'web',
|
||||
selected_provider_type: 'official',
|
||||
selected_provider_id: 'official-provider',
|
||||
selected_use_case: 'role_chat',
|
||||
})
|
||||
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(3, 'message_sent', {
|
||||
expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(3, 'message_sent', {
|
||||
app_surface: 'web',
|
||||
conversation_id: 'session-1',
|
||||
provider_type: 'official',
|
||||
@@ -555,18 +486,18 @@ describe('useAnalytics conversation product events', () => {
|
||||
trigger_method: 'text_input',
|
||||
trigger_type: 'user_action',
|
||||
})
|
||||
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(4, 'quota_limit_reached', {
|
||||
expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(4, 'quota_limit_reached', {
|
||||
limit_type: 'flux',
|
||||
current_usage: 0,
|
||||
limit_value: 0,
|
||||
entry: 'pricing',
|
||||
})
|
||||
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(5, 'upgrade_clicked', {
|
||||
expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(5, 'upgrade_clicked', {
|
||||
source_page: 'settings_flux',
|
||||
current_plan: 'flux',
|
||||
trigger: 'manual_topup',
|
||||
})
|
||||
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(6, 'feature_used', {
|
||||
expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(6, 'feature_used', {
|
||||
app_surface: 'web',
|
||||
feature_name: 'chat',
|
||||
business_domain: 'conversation',
|
||||
@@ -620,50 +551,50 @@ describe('useAnalytics conversation product events', () => {
|
||||
category: 'payment',
|
||||
})
|
||||
|
||||
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(1, 'conversation_created', {
|
||||
expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(1, 'conversation_created', {
|
||||
app_surface: 'web',
|
||||
conversation_id: 'session-1',
|
||||
source: 'new_session',
|
||||
character_id: 'character-1',
|
||||
cloud_synced: true,
|
||||
})
|
||||
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(2, 'conversation_renamed', {
|
||||
expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(2, 'conversation_renamed', {
|
||||
app_surface: 'web',
|
||||
conversation_id: 'session-1',
|
||||
source: 'sessions_drawer',
|
||||
})
|
||||
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(3, 'conversation_shared', {
|
||||
expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(3, 'conversation_shared', {
|
||||
app_surface: 'web',
|
||||
conversation_id: 'session-1',
|
||||
source: 'share_button',
|
||||
})
|
||||
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(4, 'conversation_deleted', {
|
||||
expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(4, 'conversation_deleted', {
|
||||
app_surface: 'web',
|
||||
conversation_id: 'session-1',
|
||||
message_count: 6,
|
||||
cloud_synced: true,
|
||||
})
|
||||
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(5, 'attachment_uploaded', {
|
||||
expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(5, 'attachment_uploaded', {
|
||||
app_surface: 'web',
|
||||
attachment_type: 'image',
|
||||
size_bytes: 2048,
|
||||
source: 'chat',
|
||||
success: true,
|
||||
})
|
||||
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(6, 'preset_used', {
|
||||
expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(6, 'preset_used', {
|
||||
app_surface: 'web',
|
||||
preset_id: 'preset-live2d-1',
|
||||
preset_type: 'stage_model',
|
||||
source: 'settings',
|
||||
})
|
||||
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(7, 'settings_changed', {
|
||||
expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(7, 'settings_changed', {
|
||||
app_surface: 'web',
|
||||
setting_name: 'analytics_enabled',
|
||||
previous_value: false,
|
||||
new_value: true,
|
||||
source: 'settings',
|
||||
})
|
||||
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(8, 'support_contacted', {
|
||||
expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(8, 'support_contacted', {
|
||||
app_surface: 'web',
|
||||
channel: 'discord',
|
||||
source: 'settings',
|
||||
@@ -674,7 +605,7 @@ describe('useAnalytics conversation product events', () => {
|
||||
/**
|
||||
* @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 })
|
||||
* expect(posthog.capture).toHaveBeenCalledWith('bug_report_submitted', expect.objectContaining({ category: 'update' }))
|
||||
* expect(captureAnalyticsEvent).toHaveBeenCalledWith('bug_report_submitted', expect.objectContaining({ category: 'update' }))
|
||||
*/
|
||||
it('emits feedback and bug report events with community triage tags', () => {
|
||||
const analytics = useAnalytics()
|
||||
@@ -697,7 +628,7 @@ describe('useAnalytics conversation product events', () => {
|
||||
entrypoint: 'community_manual_tag',
|
||||
})
|
||||
|
||||
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(1, 'bug_report_submitted', {
|
||||
expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(1, 'bug_report_submitted', {
|
||||
app_surface: 'web',
|
||||
source: 'app',
|
||||
category: 'update',
|
||||
@@ -708,7 +639,7 @@ describe('useAnalytics conversation product events', () => {
|
||||
include_triage_context: true,
|
||||
screenshot_attached: true,
|
||||
})
|
||||
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(2, 'feedback_submitted', {
|
||||
expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(2, 'feedback_submitted', {
|
||||
app_surface: 'web',
|
||||
source: 'discord',
|
||||
category: 'voice_input',
|
||||
@@ -728,20 +659,20 @@ describe('useAnalytics conversation product events', () => {
|
||||
analytics.trackAccountDeletionRequested()
|
||||
analytics.trackOauthCallbackFailed({ stage: 'missing_flow_state' })
|
||||
|
||||
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(1, 'password_changed', { app_surface: 'web' })
|
||||
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(2, 'password_reset_requested', { app_surface: 'web' })
|
||||
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(
|
||||
expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(1, 'password_changed', { app_surface: 'web' })
|
||||
expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(2, 'password_reset_requested', { app_surface: 'web' })
|
||||
expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
'oauth_provider_link_started',
|
||||
{ app_surface: 'web', provider: 'github' },
|
||||
{ beforeNavigation: true },
|
||||
)
|
||||
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(4, 'oauth_provider_unlinked', {
|
||||
expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(4, 'oauth_provider_unlinked', {
|
||||
app_surface: 'web',
|
||||
provider: 'google',
|
||||
})
|
||||
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(5, 'account_deletion_requested', { app_surface: 'web' })
|
||||
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(6, 'oauth_callback_failed', {
|
||||
expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(5, 'account_deletion_requested', { app_surface: 'web' })
|
||||
expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(6, 'oauth_callback_failed', {
|
||||
app_surface: 'web',
|
||||
stage: 'missing_flow_state',
|
||||
})
|
||||
@@ -755,21 +686,21 @@ describe('useAnalytics conversation product events', () => {
|
||||
analytics.trackSceneBackgroundSet({ source: 'scene_settings', cleared: true })
|
||||
analytics.trackCharacterUpdated({ character_id: 'character-1' })
|
||||
|
||||
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(1, 'card_edited', {
|
||||
expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(1, 'card_edited', {
|
||||
app_surface: 'web',
|
||||
card_id: 'card-1',
|
||||
})
|
||||
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(2, 'scene_background_set', {
|
||||
expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(2, 'scene_background_set', {
|
||||
app_surface: 'web',
|
||||
source: 'card_gallery',
|
||||
cleared: false,
|
||||
})
|
||||
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(3, 'scene_background_set', {
|
||||
expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(3, 'scene_background_set', {
|
||||
app_surface: 'web',
|
||||
source: 'scene_settings',
|
||||
cleared: true,
|
||||
})
|
||||
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(4, 'character_updated', {
|
||||
expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(4, 'character_updated', {
|
||||
character_id: 'character-1',
|
||||
})
|
||||
})
|
||||
@@ -780,11 +711,11 @@ describe('useAnalytics conversation product events', () => {
|
||||
analytics.trackDataAction({ action: 'chats_exported' })
|
||||
analytics.trackDataAction({ action: 'app_data_cleared' })
|
||||
|
||||
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(1, 'data_action', {
|
||||
expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(1, 'data_action', {
|
||||
app_surface: 'web',
|
||||
action: 'chats_exported',
|
||||
})
|
||||
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(2, 'data_action', {
|
||||
expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(2, 'data_action', {
|
||||
app_surface: 'web',
|
||||
action: 'app_data_cleared',
|
||||
})
|
||||
@@ -797,11 +728,11 @@ describe('useAnalytics conversation product events', () => {
|
||||
analytics.trackControlsIslandAction({ action: 'toggle_chat' })
|
||||
analytics.trackControlsIslandAction({ action: 'refresh_window' })
|
||||
|
||||
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(1, 'controls_island_action', {
|
||||
expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(1, 'controls_island_action', {
|
||||
action: 'toggle_chat',
|
||||
environment: 'tamagotchi',
|
||||
})
|
||||
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(
|
||||
expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'controls_island_action',
|
||||
{
|
||||
@@ -826,19 +757,19 @@ describe('useAnalytics conversation product events', () => {
|
||||
analytics.trackMcpConnectionTestRun({ success: false })
|
||||
analytics.trackDevicePairingQrShown()
|
||||
|
||||
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(1, 'spotlight_used', {})
|
||||
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(2, 'widget_opened', { widget_id: 'weather' })
|
||||
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(3, 'update_check_clicked', { channel: 'auto' })
|
||||
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(4, 'update_downloaded', { channel: 'stable', version: '0.11.0' })
|
||||
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(
|
||||
expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(1, 'spotlight_used', {})
|
||||
expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(2, 'widget_opened', { widget_id: 'weather' })
|
||||
expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(3, 'update_check_clicked', { channel: 'auto' })
|
||||
expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(4, 'update_downloaded', { channel: 'stable', version: '0.11.0' })
|
||||
expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(
|
||||
5,
|
||||
'update_install_clicked',
|
||||
{ channel: 'stable', version: '0.11.0' },
|
||||
{ beforeNavigation: true },
|
||||
)
|
||||
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(6, 'mcp_server_updated', { action: 'add' })
|
||||
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(7, 'mcp_server_updated', { action: 'remove' })
|
||||
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(8, 'mcp_connection_test_run', { success: false })
|
||||
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(9, 'device_pairing_qr_shown', {})
|
||||
expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(6, 'mcp_server_updated', { action: 'add' })
|
||||
expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(7, 'mcp_server_updated', { action: 'remove' })
|
||||
expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(8, 'mcp_connection_test_run', { success: false })
|
||||
expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(9, 'device_pairing_qr_shown', {})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -159,7 +159,7 @@ export function useAnalytics() {
|
||||
*
|
||||
* Expects:
|
||||
* - `entry_surface` is a stable identifier — don't rename without coordinating
|
||||
* PostHog funnel definitions in `docs/ai-context/metrics-ownership.md`.
|
||||
* OpenPanel funnel definitions in `docs/ai-context/metrics-ownership.md`.
|
||||
*/
|
||||
function trackPricingViewed(entrySurface: string, planPeriod?: 'monthly' | 'annual' | 'one_time') {
|
||||
if (!canCapture())
|
||||
@@ -187,7 +187,7 @@ export function useAnalytics() {
|
||||
* `window.location.href = ...`. `beforeNavigation` lets the installed
|
||||
* adapter choose a delivery mechanism that survives document unload.
|
||||
*
|
||||
* The funnel terminator `payment_completed` is forwarded to PostHog
|
||||
* The funnel terminator `payment_completed` is forwarded to OpenPanel
|
||||
* server-side by the product-events service, keyed by the Better Auth
|
||||
* user id.
|
||||
*/
|
||||
@@ -233,7 +233,7 @@ export function useAnalytics() {
|
||||
}
|
||||
|
||||
// ─── Account lifecycle (same event names as apps/ui-server-auth's
|
||||
// analytics module — both surfaces feed one PostHog series) ───────────
|
||||
// analytics module — both surfaces feed one OpenPanel series) ───────────
|
||||
|
||||
function trackPasswordChanged() {
|
||||
if (!canCapture())
|
||||
@@ -308,7 +308,7 @@ export function useAnalytics() {
|
||||
captureAnalyticsEvent('character_created', properties)
|
||||
}
|
||||
|
||||
/** Feature adoption — voice mode is a candidate retention lever; cohort comparisons live in PostHog. */
|
||||
/** Feature adoption — voice mode is a candidate retention lever; cohort comparisons live in OpenPanel. */
|
||||
function trackVoiceModeActivated(characterId?: string) {
|
||||
if (!canCapture())
|
||||
return
|
||||
@@ -336,7 +336,7 @@ export function useAnalytics() {
|
||||
/**
|
||||
* Retention cohort denominator — every chat session start. Pair with
|
||||
* `payment_completed` cohort to compute "active paying user" retention
|
||||
* curves in PostHog.
|
||||
* curves in OpenPanel.
|
||||
*/
|
||||
function trackChatSessionStarted(modelId: string, sessionIndex?: number) {
|
||||
if (!canCapture())
|
||||
@@ -344,49 +344,6 @@ export function useAnalytics() {
|
||||
captureAnalyticsEvent('chat_session_started', { model_id: modelId, ...(sessionIndex != null && { session_index: sessionIndex }) })
|
||||
}
|
||||
|
||||
/** Cost-fact event for one custom-provider generation; content is intentionally excluded. */
|
||||
function trackAiGeneration(properties: {
|
||||
conversation_id: string
|
||||
round_id: string
|
||||
provider_type: ProviderMode
|
||||
provider_id: string
|
||||
model_id: string
|
||||
usage_source: AiUsageSource
|
||||
input_tokens?: number
|
||||
output_tokens?: number
|
||||
total_tokens?: number
|
||||
}) {
|
||||
if (!canCapture())
|
||||
return
|
||||
|
||||
const totalTokens = properties.total_tokens
|
||||
?? (properties.input_tokens != null && properties.output_tokens != null
|
||||
? properties.input_tokens + properties.output_tokens
|
||||
: undefined)
|
||||
|
||||
captureAnalyticsEvent('$ai_generation', {
|
||||
$ai_trace_id: properties.conversation_id,
|
||||
$ai_session_id: properties.conversation_id,
|
||||
$ai_span_id: properties.round_id,
|
||||
$ai_model: properties.model_id,
|
||||
$ai_provider: properties.provider_id,
|
||||
...(properties.input_tokens != null && { $ai_input_tokens: properties.input_tokens }),
|
||||
...(properties.output_tokens != null && { $ai_output_tokens: properties.output_tokens }),
|
||||
...(totalTokens != null && { $ai_total_tokens: totalTokens }),
|
||||
$insert_id: `ai-generation:${properties.round_id}`,
|
||||
app_surface: getConversationAnalyticsSurface(),
|
||||
capture_surface: 'client',
|
||||
conversation_id: properties.conversation_id,
|
||||
conversation_id_source: 'client_runtime',
|
||||
round_id: properties.round_id,
|
||||
provider_type: properties.provider_type,
|
||||
usage_source: properties.usage_source,
|
||||
token_usage_available: properties.usage_source !== 'unavailable',
|
||||
cost_usd_source: 'unavailable',
|
||||
cost_usd_known: false,
|
||||
})
|
||||
}
|
||||
|
||||
/** Closing event for one full message round (user send → assistant render). */
|
||||
function trackMessageRound(properties: ChatRoundCorrelationProperties & {
|
||||
duration_ms: number
|
||||
@@ -1047,7 +1004,6 @@ export function useAnalytics() {
|
||||
trackModelSwitched,
|
||||
trackChatSessionStarted,
|
||||
|
||||
trackAiGeneration,
|
||||
trackMessageRound,
|
||||
trackMessageRoundFailed,
|
||||
trackMessageSent,
|
||||
|
||||
@@ -4,7 +4,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { authedFetch } from './auth-fetch'
|
||||
|
||||
const posthogMocks = vi.hoisted(() => ({
|
||||
const analyticsMocks = vi.hoisted(() => ({
|
||||
getAnalyticsIdentitySnapshot: vi.fn<() => { distinctId: string, sessionId: string } | null>(() => ({
|
||||
distinctId: 'distinct-1',
|
||||
sessionId: 'session-1',
|
||||
@@ -12,7 +12,7 @@ const posthogMocks = vi.hoisted(() => ({
|
||||
}))
|
||||
|
||||
vi.mock('./product-signals', () => ({
|
||||
getAnalyticsIdentitySnapshot: posthogMocks.getAnalyticsIdentitySnapshot,
|
||||
getAnalyticsIdentitySnapshot: analyticsMocks.getAnalyticsIdentitySnapshot,
|
||||
}))
|
||||
|
||||
describe('authedFetch', () => {
|
||||
@@ -20,13 +20,13 @@ describe('authedFetch', () => {
|
||||
vi.restoreAllMocks()
|
||||
setActivePinia(createPinia())
|
||||
useAuthStore().token = 'access-token'
|
||||
posthogMocks.getAnalyticsIdentitySnapshot.mockReturnValue({
|
||||
analyticsMocks.getAnalyticsIdentitySnapshot.mockReturnValue({
|
||||
distinctId: 'distinct-1',
|
||||
sessionId: 'session-1',
|
||||
})
|
||||
})
|
||||
|
||||
it('sends PostHog identity headers with authenticated API requests', async () => {
|
||||
it('sends analytics identity headers with authenticated API requests', async () => {
|
||||
const fetchMock = vi.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise<Response>>(async () => new Response('{}', { status: 200 }))
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
@@ -37,12 +37,12 @@ describe('authedFetch', () => {
|
||||
const headers = fetchMock.mock.calls[0]?.[1]?.headers
|
||||
expect(headers).toBeInstanceOf(Headers)
|
||||
expect((headers as Headers).get('Authorization')).toBe('Bearer access-token')
|
||||
expect((headers as Headers).get('x-posthog-distinct-id')).toBe('distinct-1')
|
||||
expect((headers as Headers).get('x-posthog-session-id')).toBe('session-1')
|
||||
expect((headers as Headers).get('x-openpanel-device-id')).toBe('distinct-1')
|
||||
expect((headers as Headers).get('x-openpanel-session-id')).toBe('session-1')
|
||||
})
|
||||
|
||||
it('omits PostHog identity headers when analytics has no active identity', async () => {
|
||||
posthogMocks.getAnalyticsIdentitySnapshot.mockReturnValue(null)
|
||||
it('omits analytics identity headers when analytics has no active identity', async () => {
|
||||
analyticsMocks.getAnalyticsIdentitySnapshot.mockReturnValue(null)
|
||||
const fetchMock = vi.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise<Response>>(async () => new Response('{}', { status: 200 }))
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
@@ -51,11 +51,11 @@ describe('authedFetch', () => {
|
||||
const headers = fetchMock.mock.calls[0]?.[1]?.headers
|
||||
expect(headers).toBeInstanceOf(Headers)
|
||||
expect((headers as Headers).get('Authorization')).toBe('Bearer access-token')
|
||||
expect((headers as Headers).get('x-posthog-distinct-id')).toBeNull()
|
||||
expect((headers as Headers).get('x-posthog-session-id')).toBeNull()
|
||||
expect((headers as Headers).get('x-openpanel-device-id')).toBeNull()
|
||||
expect((headers as Headers).get('x-openpanel-session-id')).toBeNull()
|
||||
})
|
||||
|
||||
it('does not send PostHog identity headers to non-server origins', async () => {
|
||||
it('does not send analytics identity headers to non-server origins', async () => {
|
||||
const fetchMock = vi.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise<Response>>(async () => new Response('{}', { status: 200 }))
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
@@ -64,7 +64,7 @@ describe('authedFetch', () => {
|
||||
const headers = fetchMock.mock.calls[0]?.[1]?.headers
|
||||
expect(headers).toBeInstanceOf(Headers)
|
||||
expect((headers as Headers).get('Authorization')).toBe('Bearer access-token')
|
||||
expect((headers as Headers).get('x-posthog-distinct-id')).toBeNull()
|
||||
expect((headers as Headers).get('x-posthog-session-id')).toBeNull()
|
||||
expect((headers as Headers).get('x-openpanel-device-id')).toBeNull()
|
||||
expect((headers as Headers).get('x-openpanel-session-id')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -27,11 +27,11 @@ export async function authedFetch(
|
||||
const headers = new Headers(init?.headers)
|
||||
if (token)
|
||||
headers.set('Authorization', `Bearer ${token}`)
|
||||
const posthogIdentity = shouldAttachPosthogIdentity(input) ? getAnalyticsIdentitySnapshot() : null
|
||||
if (posthogIdentity) {
|
||||
headers.set('x-posthog-distinct-id', posthogIdentity.distinctId)
|
||||
if (posthogIdentity.sessionId)
|
||||
headers.set('x-posthog-session-id', posthogIdentity.sessionId)
|
||||
const openpanelIdentity = shouldAttachOpenpanelIdentity(input) ? getAnalyticsIdentitySnapshot() : null
|
||||
if (openpanelIdentity) {
|
||||
headers.set('x-openpanel-device-id', openpanelIdentity.distinctId)
|
||||
if (openpanelIdentity.sessionId)
|
||||
headers.set('x-openpanel-session-id', openpanelIdentity.sessionId)
|
||||
}
|
||||
return fetch(input, { ...init, headers, credentials: 'omit' })
|
||||
}
|
||||
@@ -59,7 +59,7 @@ export async function authedFetch(
|
||||
return retried
|
||||
}
|
||||
|
||||
function shouldAttachPosthogIdentity(input: RequestInfo | URL): boolean {
|
||||
function shouldAttachOpenpanelIdentity(input: RequestInfo | URL): boolean {
|
||||
const url = typeof input === 'string'
|
||||
? input
|
||||
: input instanceof URL ? input.toString() : input.url
|
||||
|
||||
@@ -186,7 +186,7 @@ export function configureAnalyticsAdapter(loader: AnalyticsAdapterLoader): void
|
||||
}
|
||||
|
||||
export function isAnalyticsAvailableInBuild(): boolean {
|
||||
return isEnvTruthy(import.meta.env.VITE_ENABLE_POSTHOG)
|
||||
return isEnvTruthy(import.meta.env.VITE_ENABLE_ANALYTICS)
|
||||
}
|
||||
|
||||
export function enableAnalyticsCapture(): boolean {
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
import { defineEvent } from '../../../utils/dsl'
|
||||
|
||||
export const aiGenerationEvent = defineEvent<{
|
||||
conversation_id: string
|
||||
round_id: string
|
||||
provider_type: 'official' | 'custom' | 'unknown'
|
||||
provider_id: string
|
||||
model_id: string
|
||||
usage_source: 'reported' | 'estimated' | 'unavailable'
|
||||
input_tokens?: number
|
||||
output_tokens?: number
|
||||
total_tokens?: number
|
||||
}>('$ai_generation')
|
||||
@@ -1,3 +1,2 @@
|
||||
export * from './generation'
|
||||
export * from './message'
|
||||
export * from './round'
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { AnalyticsRecorder } from '../../index'
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { aiGenerationEvent, messageSentEvent } from './events'
|
||||
import { messageRoundEvent, messageSentEvent } from './events'
|
||||
import { createChatAnalyticsHooks } from './runtime'
|
||||
|
||||
function createRecorder(): AnalyticsRecorder {
|
||||
@@ -76,44 +76,34 @@ describe('createChatAnalyticsHooks', () => {
|
||||
expect(analytics.recordFirstMessage).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('records generation usage only for custom providers', () => {
|
||||
it('keeps token usage on completed rounds without a second generation event', () => {
|
||||
const analytics = createRecorder()
|
||||
const hooks = createChatAnalyticsHooks({
|
||||
analytics,
|
||||
getSessionMessages: () => [],
|
||||
})
|
||||
const hooks = createChatAnalyticsHooks({ analytics, getSessionMessages: () => [] })
|
||||
|
||||
hooks.onLlmGeneration?.({
|
||||
expect(hooks).not.toHaveProperty('onLlmGeneration')
|
||||
hooks.onMessageRound?.({
|
||||
conversationId: 'session-1',
|
||||
roundId: 'round-1',
|
||||
turnIndex: 1,
|
||||
model: 'custom-model',
|
||||
provider: 'custom-provider',
|
||||
durationMs: 120,
|
||||
hasVoice: false,
|
||||
inputTokens: 12,
|
||||
outputTokens: 8,
|
||||
totalTokens: 20,
|
||||
usageSource: 'reported',
|
||||
})
|
||||
hooks.onLlmGeneration?.({
|
||||
conversationId: 'session-1',
|
||||
roundId: 'round-2',
|
||||
turnIndex: 2,
|
||||
model: 'official-model',
|
||||
provider: 'official-provider-chat',
|
||||
usageSource: 'reported',
|
||||
})
|
||||
|
||||
expect(analytics.emit).toHaveBeenCalledTimes(1)
|
||||
expect(analytics.emit).toHaveBeenCalledWith(aiGenerationEvent, {
|
||||
expect(analytics.emit).toHaveBeenCalledWith(messageRoundEvent, expect.objectContaining({
|
||||
conversation_id: 'session-1',
|
||||
round_id: 'round-1',
|
||||
provider_type: 'custom',
|
||||
provider_id: 'custom-provider',
|
||||
model_id: 'custom-model',
|
||||
usage_source: 'reported',
|
||||
model: 'custom-model',
|
||||
duration_ms: 120,
|
||||
input_tokens: 12,
|
||||
output_tokens: 8,
|
||||
total_tokens: 20,
|
||||
})
|
||||
usage_source: 'reported',
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,7 +5,6 @@ import type { AnalyticsRecorder } from '../../index'
|
||||
|
||||
import { getAnalytics } from '../../index'
|
||||
import {
|
||||
aiGenerationEvent,
|
||||
messageRoundEvent,
|
||||
messageRoundFailedEvent,
|
||||
messageSentEvent,
|
||||
@@ -14,7 +13,6 @@ import { getProviderMode } from './types'
|
||||
|
||||
type ChatAnalyticsCallbacks = Pick<
|
||||
ChatOrchestratorRuntimeDeps,
|
||||
| 'onLlmGeneration'
|
||||
| 'onMessageRound'
|
||||
| 'onMessageRoundFailed'
|
||||
| 'onTrackFirstMessage'
|
||||
@@ -40,23 +38,6 @@ export function createChatAnalyticsHooks(options: CreateChatAnalyticsHooksOption
|
||||
|
||||
return {
|
||||
onTrackFirstMessage: () => analytics.recordFirstMessage(),
|
||||
onLlmGeneration: ({ conversationId, roundId, model, provider, inputTokens, outputTokens, totalTokens, usageSource }) => {
|
||||
const providerType = getProviderMode(provider)
|
||||
if (providerType !== 'custom')
|
||||
return
|
||||
|
||||
analytics.emit(aiGenerationEvent, {
|
||||
conversation_id: conversationId,
|
||||
round_id: roundId,
|
||||
provider_type: providerType,
|
||||
provider_id: provider,
|
||||
model_id: model,
|
||||
usage_source: usageSource,
|
||||
input_tokens: inputTokens,
|
||||
output_tokens: outputTokens,
|
||||
total_tokens: totalTokens,
|
||||
})
|
||||
},
|
||||
onMessageRound: ({ conversationId, roundId, turnIndex, durationMs, hasVoice, model, inputTokens, outputTokens, totalTokens, usageSource }) => {
|
||||
analytics.emit(messageRoundEvent, {
|
||||
conversation_id: conversationId,
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { createOpenpanelAdapter } from './openpanel'
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('openPanel browser adapter', () => {
|
||||
it('drops disabled events and isolates account identity across logout', async () => {
|
||||
const requests: RequestInit[] = []
|
||||
const destinations: string[] = []
|
||||
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => {
|
||||
destinations.push(String(input))
|
||||
if (init)
|
||||
requests.push(init)
|
||||
return new Response(JSON.stringify({ deviceId: 'server-device', sessionId: 'session-1' }), { status: 200 })
|
||||
})
|
||||
const adapter = createOpenpanelAdapter({ enabled: false })
|
||||
expect(adapter.capture('disabled_event', {})).toBe(false)
|
||||
expect(adapter.getIdentitySnapshot()).toBeNull()
|
||||
expect(adapter.setCaptureEnabled(true)).toBe(true)
|
||||
const firstDevice = adapter.getIdentitySnapshot()?.distinctId
|
||||
expect(firstDevice).toBeTruthy()
|
||||
adapter.identify('alice')
|
||||
adapter.capture('checkout_started', {}, { beforeNavigation: true })
|
||||
await vi.waitFor(() => expect(requests.some(request => String(request.body).includes('checkout_started'))).toBe(true))
|
||||
const checkout = requests.find(request => String(request.body).includes('checkout_started'))!
|
||||
expect(checkout.keepalive).toBe(true)
|
||||
expect(JSON.parse(String(checkout.body)).payload.profileId).toBe('alice')
|
||||
expect(JSON.parse(String(checkout.body)).payload.properties.__deviceId).toBe(firstDevice)
|
||||
|
||||
const originalUrl = window.location.href
|
||||
history.replaceState(null, '', `${window.location.pathname}?code=private#fragment`)
|
||||
await vi.waitFor(() => expect(requests.some(request => String(request.body).includes('screen_view'))).toBe(true))
|
||||
await new Promise(resolve => setTimeout(resolve, 80))
|
||||
adapter.capture('navigation_metadata', {})
|
||||
await vi.waitFor(() => expect(requests.some(request => String(request.body).includes('navigation_metadata'))).toBe(true))
|
||||
const navigation = requests.find(request => String(request.body).includes('navigation_metadata'))!
|
||||
expect(String(navigation.body)).not.toContain('code=private')
|
||||
expect(String(navigation.body)).not.toContain('#fragment')
|
||||
expect(JSON.parse(String(navigation.body)).payload.properties.__path).toBe(originalUrl.split(/[?#]/, 1)[0])
|
||||
expect(requests.some(request => String(request.body).includes('code=private'))).toBe(false)
|
||||
|
||||
adapter.resetIdentity()
|
||||
expect(adapter.getIdentitySnapshot()?.distinctId).not.toBe(firstDevice)
|
||||
adapter.identify('bob')
|
||||
adapter.capture('new_account', {})
|
||||
await vi.waitFor(() => expect(requests.some(request => String(request.body).includes('new_account'))).toBe(true))
|
||||
const bob = requests.find(request => String(request.body).includes('new_account'))!
|
||||
expect(JSON.parse(String(bob.body)).payload.profileId).toBe('bob')
|
||||
expect(JSON.parse(String(bob.body)).payload.properties.__deviceId).not.toBe(firstDevice)
|
||||
|
||||
adapter.setCaptureEnabled(false)
|
||||
history.replaceState(null, '', originalUrl)
|
||||
adapter.capture('disabled_event', {})
|
||||
adapter.setCaptureEnabled(true)
|
||||
adapter.capture('enabled_event', {})
|
||||
await vi.waitFor(() => expect(requests.some(request => String(request.body).includes('enabled_event'))).toBe(true))
|
||||
expect(requests.some(request => String(request.body).includes('disabled_event'))).toBe(false)
|
||||
expect(destinations.every(url => url.startsWith('https://analytics.airi.build/api/'))).toBe(true)
|
||||
expect(requests.every(request => !new Headers(request.headers).has('openpanel-client-secret'))).toBe(true)
|
||||
adapter.setCaptureEnabled(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,116 @@
|
||||
import type { AnalyticsAdapter, AnalyticsAdapterOptions } from './client'
|
||||
|
||||
import { OpenPanel } from '@openpanel/web'
|
||||
import { isStageCapacitor, isStageTamagotchi } from '@proj-airi/stage-shared'
|
||||
import { OPENPANEL_CONFIG } from '@proj-airi/stage-shared/analytics/openpanel'
|
||||
|
||||
const deviceStorageKey = 'airi:openpanel-device-id'
|
||||
|
||||
function loadDeviceId(): string {
|
||||
try {
|
||||
const stored = sessionStorage.getItem(deviceStorageKey)
|
||||
if (stored)
|
||||
return stored
|
||||
}
|
||||
catch {
|
||||
// Storage can be unavailable in embedded browsers. Keep this visit in memory.
|
||||
}
|
||||
return rotateDeviceId()
|
||||
}
|
||||
|
||||
function rotateDeviceId(): string {
|
||||
const id = crypto.randomUUID()
|
||||
try {
|
||||
sessionStorage.setItem(deviceStorageKey, id)
|
||||
}
|
||||
catch {
|
||||
// The in-memory identity still isolates accounts when storage is unavailable.
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
/** Sends product events to OpenPanel under the current consent and identity state. */
|
||||
export function createOpenpanelAdapter(options: AnalyticsAdapterOptions): AnalyticsAdapter {
|
||||
let enabled = options.enabled
|
||||
// Rotate the device on logout. Clearing SDK fields alone reuses its
|
||||
// server-derived fingerprint and can link two accounts in one browser.
|
||||
let deviceId = enabled ? loadDeviceId() : undefined
|
||||
const panel = new OpenPanel({
|
||||
...OPENPANEL_CONFIG,
|
||||
// Consent must drop events. The SDK's disabled option queues them instead.
|
||||
filter(payload) {
|
||||
if (!enabled)
|
||||
return false
|
||||
// OAuth codes and other query values must not enter analytics.
|
||||
if (payload.type === 'track' && payload.payload.properties) {
|
||||
for (const key of ['__path', '__referrer']) {
|
||||
const value = payload.payload.properties[key]
|
||||
if (typeof value === 'string')
|
||||
payload.payload.properties[key] = value.split(/[?#]/, 1)[0]
|
||||
}
|
||||
}
|
||||
return true
|
||||
},
|
||||
trackScreenViews: true,
|
||||
trackOutgoingLinks: false,
|
||||
trackAttributes: false,
|
||||
})
|
||||
panel.setGlobalProperties({
|
||||
app_surface: isStageTamagotchi() ? 'electron' : isStageCapacitor() ? 'mobile' : 'web',
|
||||
__deviceId: deviceId,
|
||||
})
|
||||
|
||||
return {
|
||||
capture(name, properties) {
|
||||
if (!enabled)
|
||||
return false
|
||||
|
||||
// The SDK uses fetch keepalive for normal events, including navigation.
|
||||
void panel.track(name, { ...properties, __deviceId: deviceId }).catch(() => console.warn('[analytics] Product event delivery failed'))
|
||||
return true
|
||||
},
|
||||
getIdentitySnapshot() {
|
||||
if (!enabled)
|
||||
return null
|
||||
if (!deviceId)
|
||||
return null
|
||||
return { distinctId: deviceId }
|
||||
},
|
||||
identify(userId) {
|
||||
if (!enabled)
|
||||
return
|
||||
panel.identify({ profileId: userId })
|
||||
},
|
||||
registerBuildInfo(buildInfo) {
|
||||
panel.setGlobalProperties({
|
||||
app_branch: buildInfo.branch,
|
||||
app_build_time: buildInfo.builtOn,
|
||||
app_commit: buildInfo.commit,
|
||||
app_version: buildInfo.version && buildInfo.version !== '0.0.0' ? buildInfo.version : 'dev',
|
||||
})
|
||||
},
|
||||
resetIdentity() {
|
||||
panel.clear()
|
||||
deviceId = enabled ? rotateDeviceId() : undefined
|
||||
panel.setGlobalProperties({ __deviceId: deviceId })
|
||||
},
|
||||
setCaptureEnabled(value) {
|
||||
enabled = value
|
||||
if (!value) {
|
||||
panel.clear()
|
||||
deviceId = undefined
|
||||
try {
|
||||
sessionStorage.removeItem(deviceStorageKey)
|
||||
}
|
||||
catch {
|
||||
// Capture remains disabled even when browser storage is unavailable.
|
||||
}
|
||||
}
|
||||
else if (!deviceId) {
|
||||
deviceId = rotateDeviceId()
|
||||
}
|
||||
panel.setGlobalProperties({ __deviceId: deviceId })
|
||||
return enabled
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { createPosthogAdapter } from './posthog'
|
||||
|
||||
const posthogMocks = vi.hoisted(() => ({
|
||||
capture: vi.fn(),
|
||||
get_distinct_id: vi.fn(() => 'distinct-1'),
|
||||
get_session_id: vi.fn(() => 'session-1'),
|
||||
has_opted_out_capturing: vi.fn(() => false),
|
||||
init: vi.fn(),
|
||||
register: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('posthog-js', () => ({ default: posthogMocks }))
|
||||
|
||||
vi.mock('@proj-airi/stage-shared', () => ({
|
||||
isStageCapacitor: () => false,
|
||||
isStageTamagotchi: () => false,
|
||||
}))
|
||||
|
||||
vi.mock('@proj-airi/stage-shared/analytics/posthog', () => ({
|
||||
DEFAULT_POSTHOG_CONFIG: {},
|
||||
POSTHOG_PROJECT_KEY: 'test-project-key',
|
||||
}))
|
||||
|
||||
describe('posthog analytics adapter', () => {
|
||||
it('registers the runtime under the dedicated app_surface property', () => {
|
||||
createPosthogAdapter({ enabled: true })
|
||||
expect(posthogMocks.register).toHaveBeenCalledWith({ app_surface: 'web' })
|
||||
})
|
||||
|
||||
it('exposes the current provider identity for server-side conversion linking', () => {
|
||||
const adapter = createPosthogAdapter({ enabled: true })
|
||||
|
||||
expect(adapter.getIdentitySnapshot()).toEqual({
|
||||
distinctId: 'distinct-1',
|
||||
sessionId: 'session-1',
|
||||
})
|
||||
})
|
||||
|
||||
it('maps the provider-neutral navigation hint to unload-safe delivery', () => {
|
||||
const adapter = createPosthogAdapter({ enabled: true })
|
||||
|
||||
adapter.capture('checkout_started', { plan_id: 'monthly' }, { beforeNavigation: true })
|
||||
|
||||
expect(posthogMocks.capture).toHaveBeenCalledWith(
|
||||
'checkout_started',
|
||||
{ plan_id: 'monthly' },
|
||||
{ send_instantly: true, transport: 'sendBeacon' },
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,79 +0,0 @@
|
||||
import type { AboutBuildInfo } from '../../components/scenarios/about/types'
|
||||
import type { AnalyticsAdapter, AnalyticsAdapterOptions } from './client'
|
||||
|
||||
import posthog from 'posthog-js'
|
||||
|
||||
import { isStageCapacitor, isStageTamagotchi } from '@proj-airi/stage-shared'
|
||||
import {
|
||||
DEFAULT_POSTHOG_CONFIG,
|
||||
POSTHOG_PROJECT_KEY,
|
||||
} from '@proj-airi/stage-shared/analytics/posthog'
|
||||
|
||||
/** Creates and initializes the default PostHog adapter. */
|
||||
export function createPosthogAdapter(options: AnalyticsAdapterOptions): AnalyticsAdapter {
|
||||
posthog.init(POSTHOG_PROJECT_KEY, {
|
||||
...DEFAULT_POSTHOG_CONFIG,
|
||||
opt_out_capturing_by_default: !options.enabled,
|
||||
})
|
||||
posthog.register({ app_surface: currentSurface() })
|
||||
|
||||
return {
|
||||
capture(name, properties, captureOptions) {
|
||||
if (posthog.has_opted_out_capturing())
|
||||
return false
|
||||
posthog.capture(
|
||||
name,
|
||||
{ ...properties },
|
||||
captureOptions?.beforeNavigation
|
||||
? { send_instantly: true, transport: 'sendBeacon' }
|
||||
: undefined,
|
||||
)
|
||||
return true
|
||||
},
|
||||
getIdentitySnapshot() {
|
||||
if (posthog.has_opted_out_capturing())
|
||||
return null
|
||||
|
||||
const distinctId = posthog.get_distinct_id()
|
||||
if (!distinctId)
|
||||
return null
|
||||
|
||||
const sessionId = posthog.get_session_id()
|
||||
return { distinctId, ...(sessionId && { sessionId }) }
|
||||
},
|
||||
identify(userId) {
|
||||
if (!posthog.has_opted_out_capturing())
|
||||
posthog.identify(userId)
|
||||
},
|
||||
registerBuildInfo(buildInfo: AboutBuildInfo) {
|
||||
posthog.register({
|
||||
app_branch: buildInfo.branch,
|
||||
app_build_time: buildInfo.builtOn,
|
||||
app_commit: buildInfo.commit,
|
||||
app_version: (buildInfo.version && buildInfo.version !== '0.0.0') ? buildInfo.version : 'dev',
|
||||
})
|
||||
},
|
||||
resetIdentity() {
|
||||
posthog.reset()
|
||||
},
|
||||
setCaptureEnabled(enabled) {
|
||||
if (enabled) {
|
||||
if (posthog.has_opted_out_capturing())
|
||||
posthog.opt_in_capturing()
|
||||
return true
|
||||
}
|
||||
|
||||
if (!posthog.has_opted_out_capturing())
|
||||
posthog.opt_out_capturing()
|
||||
return false
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function currentSurface(): 'electron' | 'mobile' | 'web' {
|
||||
if (isStageTamagotchi())
|
||||
return 'electron'
|
||||
if (isStageCapacitor())
|
||||
return 'mobile'
|
||||
return 'web'
|
||||
}
|
||||
@@ -49,7 +49,6 @@ const ioTracerMocks = vi.hoisted(() => {
|
||||
const llmStreamMock = vi.fn()
|
||||
const trackFirstMessageMock = vi.fn()
|
||||
const chatAnalyticsMocks = vi.hoisted(() => ({
|
||||
trackAiGeneration: vi.fn(),
|
||||
trackMessageRound: vi.fn(),
|
||||
trackMessageRoundFailed: vi.fn(),
|
||||
trackMessageSent: vi.fn(),
|
||||
@@ -99,9 +98,6 @@ vi.mock('../libs/product-signals', () => ({
|
||||
getAnalytics: () => ({
|
||||
emit: (event: { name: string }, properties: unknown) => {
|
||||
switch (event.name) {
|
||||
case '$ai_generation':
|
||||
chatAnalyticsMocks.trackAiGeneration(properties)
|
||||
break
|
||||
case 'message_round':
|
||||
chatAnalyticsMocks.trackMessageRound(properties)
|
||||
break
|
||||
@@ -450,7 +446,7 @@ describe('chat store contract', () => {
|
||||
expect(chatAnalyticsMocks.trackMessageRound).toHaveBeenCalledWith(expect.objectContaining(correlation))
|
||||
})
|
||||
|
||||
it('captures custom-provider usage once and leaves official generation capture to the server', async () => {
|
||||
it('keeps custom and official provider usage in completed message rounds', async () => {
|
||||
llmStreamMock.mockImplementation(async (_model: string, _chatProvider: ChatProvider, _messages: Message[], options: any) => {
|
||||
await options.onStreamEvent({ type: 'text-delta', text: 'ok' })
|
||||
await options.onStreamEvent({ type: 'finish', finishReason: 'stop' })
|
||||
@@ -468,26 +464,26 @@ describe('chat store contract', () => {
|
||||
chatProvider: provider,
|
||||
})
|
||||
|
||||
expect(chatAnalyticsMocks.trackAiGeneration).toHaveBeenCalledWith({
|
||||
conversation_id: 'session-1',
|
||||
round_id: expect.any(String),
|
||||
provider_type: 'custom',
|
||||
provider_id: 'mock-provider',
|
||||
model_id: 'gpt-test',
|
||||
expect(chatAnalyticsMocks.trackMessageRound).toHaveBeenCalledTimes(1)
|
||||
expect(chatAnalyticsMocks.trackMessageRound).toHaveBeenCalledWith(expect.objectContaining({
|
||||
model: 'gpt-test',
|
||||
usage_source: 'reported',
|
||||
input_tokens: 12,
|
||||
output_tokens: 8,
|
||||
total_tokens: 20,
|
||||
})
|
||||
}))
|
||||
|
||||
chatAnalyticsMocks.trackAiGeneration.mockClear()
|
||||
activeProviderRef.value = 'official-provider'
|
||||
await store.ingest('official turn', {
|
||||
model: 'chat-auto',
|
||||
chatProvider: provider,
|
||||
})
|
||||
|
||||
expect(chatAnalyticsMocks.trackAiGeneration).not.toHaveBeenCalled()
|
||||
expect(chatAnalyticsMocks.trackMessageRound).toHaveBeenCalledTimes(2)
|
||||
expect(chatAnalyticsMocks.trackMessageRound).toHaveBeenLastCalledWith(expect.objectContaining({
|
||||
model: 'chat-auto',
|
||||
total_tokens: 20,
|
||||
}))
|
||||
expect(llmStreamMock.mock.calls[1]?.[3]?.headers).toEqual({
|
||||
[AIRI_CHAT_APP_SURFACE_HEADER]: 'web',
|
||||
[AIRI_CHAT_SESSION_ID_HEADER]: 'session-1',
|
||||
@@ -525,7 +521,7 @@ describe('chat store contract', () => {
|
||||
// ROOT CAUSE:
|
||||
//
|
||||
// One successful send emitted both the canonical message/latency events
|
||||
// and four generic aliases, multiplying PostHog volume without adding a
|
||||
// and four generic aliases, multiplying event volume without adding a
|
||||
// distinct product decision.
|
||||
it('does not emit redundant generic chat aliases for a successful send', async () => {
|
||||
llmStreamMock.mockImplementation(async (_model: string, _chatProvider: ChatProvider, _messages: Message[], options: any) => {
|
||||
|
||||
Reference in New Issue
Block a user