refactor(analytics): replace PostHog with OpenPanel (#2480)

This commit is contained in:
RainbowBird
2026-09-09 08:26:18 +00:00
committed by GitHub
parent 5e78b64e35
commit 66f0d45028
68 changed files with 802 additions and 1252 deletions
-1
View File
@@ -51,7 +51,6 @@
"nanoid": "catalog:",
"node-vibrant": "catalog:",
"pinia": "catalog:",
"posthog-js": "catalog:",
"reka-ui": "catalog:",
"unspeech": "catalog:xsai",
"vue": "catalog:",
-1
View File
@@ -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',
+1 -2
View File
@@ -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>
+1 -1
View File
@@ -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,
+13 -13
View File
@@ -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()
})
})
+6 -6
View File
@@ -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) => {