From 66f0d45028adbfdd68386c3cd3ba8de8aaed8c0e Mon Sep 17 00:00:00 2001 From: RainbowBird Date: Wed, 9 Sep 2026 16:26:18 +0800 Subject: [PATCH] refactor(analytics): replace PostHog with OpenPanel (#2480) --- .../workflows/deploy-cloudflare-auth-ui.yml | 10 +- .../workflows/deploy-cloudflare-workers.yml | 10 +- .../workflows/deploy-huggingface-spaces.yml | 2 +- .github/workflows/release-docker.yaml | 2 +- .github/workflows/release-pocket-android.yml | 2 +- .github/workflows/release-pocket-ios.yml | 2 +- .../workflows/release-tamagotchi-steam.yml | 2 +- .github/workflows/release-tamagotchi.yml | 2 +- apps/stage-pocket/package.json | 1 - apps/stage-pocket/src/main.ts | 4 +- apps/stage-tamagotchi/package.json | 1 - apps/stage-tamagotchi/src/renderer/main.ts | 4 +- apps/stage-web/Dockerfile | 4 +- apps/stage-web/package.json | 1 - apps/stage-web/src/main.ts | 4 +- .../characters/components/CharacterDialog.vue | 2 +- apps/stage-web/vite.config.ts | 2 +- apps/ui-server-auth/package.json | 2 +- apps/ui-server-auth/src/main.ts | 6 +- .../modules/analytics-adapters/openpanel.ts | 34 +++ .../src/modules/analytics-adapters/posthog.ts | 28 --- .../src/modules/analytics.test.ts | 2 +- apps/ui-server-auth/vite.config.ts | 2 +- deploy/openpanel/README.md | 83 +++++++ docs/.vitepress/modules/openpanel.ts | 23 ++ docs/.vitepress/modules/posthog.ts | 12 - docs/.vitepress/theme/index.ts | 2 +- docs/package.json | 2 +- package.json | 1 - packages/stage-layouts/package.json | 1 - packages/stage-pages/package.json | 1 - .../stage-pages/src/pages/settings/flux.vue | 11 +- packages/stage-shared/package.json | 3 +- .../stage-shared/src/analytics/openpanel.ts | 5 + .../stage-shared/src/analytics/posthog.ts | 25 -- packages/stage-ui/package.json | 2 +- .../src/composables/use-analytics.test.ts | 219 ++++++------------ .../stage-ui/src/composables/use-analytics.ts | 54 +---- packages/stage-ui/src/libs/auth-fetch.test.ts | 26 +-- packages/stage-ui/src/libs/auth-fetch.ts | 12 +- .../src/libs/product-signals/client.ts | 2 +- .../events/chat/events/generation.ts | 13 -- .../events/chat/events/index.ts | 1 - .../events/chat/runtime.test.ts | 34 +-- .../product-signals/events/chat/runtime.ts | 19 -- .../product-signals/openpanel.browser.test.ts | 65 ++++++ .../src/libs/product-signals/openpanel.ts | 116 ++++++++++ .../src/libs/product-signals/posthog.test.ts | 52 ----- .../src/libs/product-signals/posthog.ts | 79 ------- .../stage-ui/src/stores/chat.contract.test.ts | 26 +-- pnpm-lock.yaml | 196 +++++++--------- pnpm-workspace.yaml | 4 +- server/apps/api/package.json | 2 +- server/apps/api/src/app.test.ts | 1 - server/apps/api/src/app.ts | 26 +-- server/apps/api/src/libs/env.ts | 10 +- .../api/src/routes/openai/v1/analytics.ts | 15 +- .../v1/operations/chat-completions/index.ts | 93 +------- .../api/src/routes/openai/v1/route.test.ts | 87 +------ .../src/routes/stripe/operations/checkout.ts | 18 +- .../src/routes/stripe/operations/webhook.ts | 11 +- .../apps/api/src/routes/stripe/route.test.ts | 28 ++- .../src/services/adapters/openpanel.test.ts | 58 +++++ .../api/src/services/adapters/openpanel.ts | 53 +++++ .../apps/api/src/services/adapters/posthog.ts | 81 ------- .../services/domain/product-events.test.ts | 194 ++++------------ .../api/src/services/domain/product-events.ts | 156 ++----------- vite-env.d.ts | 3 +- 68 files changed, 802 insertions(+), 1252 deletions(-) create mode 100644 apps/ui-server-auth/src/modules/analytics-adapters/openpanel.ts delete mode 100644 apps/ui-server-auth/src/modules/analytics-adapters/posthog.ts create mode 100644 deploy/openpanel/README.md create mode 100644 docs/.vitepress/modules/openpanel.ts delete mode 100644 docs/.vitepress/modules/posthog.ts create mode 100644 packages/stage-shared/src/analytics/openpanel.ts delete mode 100644 packages/stage-shared/src/analytics/posthog.ts delete mode 100644 packages/stage-ui/src/libs/product-signals/events/chat/events/generation.ts create mode 100644 packages/stage-ui/src/libs/product-signals/openpanel.browser.test.ts create mode 100644 packages/stage-ui/src/libs/product-signals/openpanel.ts delete mode 100644 packages/stage-ui/src/libs/product-signals/posthog.test.ts delete mode 100644 packages/stage-ui/src/libs/product-signals/posthog.ts create mode 100644 server/apps/api/src/services/adapters/openpanel.test.ts create mode 100644 server/apps/api/src/services/adapters/openpanel.ts delete mode 100644 server/apps/api/src/services/adapters/posthog.ts diff --git a/.github/workflows/deploy-cloudflare-auth-ui.yml b/.github/workflows/deploy-cloudflare-auth-ui.yml index ac9546e3c..73db64583 100644 --- a/.github/workflows/deploy-cloudflare-auth-ui.yml +++ b/.github/workflows/deploy-cloudflare-auth-ui.yml @@ -49,16 +49,8 @@ jobs: - name: Build ui-server-auth run: pnpm -F @proj-airi/ui-server-auth run build env: - VITE_ENABLE_POSTHOG: 'true' + VITE_ENABLE_ANALYTICS: 'true' VITE_SERVER_URL: 'https://api.airi.build' - - run: | - CI=true pnpm install -g @posthog/cli - pnpm exec posthog-cli sourcemap inject --directory ./apps/ui-server-auth/dist - pnpm exec posthog-cli sourcemap upload --directory ./apps/ui-server-auth/dist - env: - POSTHOG_CLI_ENV_ID: ${{ secrets.POSTHOG_ENV_ID }} - POSTHOG_CLI_TOKEN: ${{ secrets.POSTHOG_TOKEN }} - continue-on-error: true - uses: cloudflare/wrangler-action@v4 with: diff --git a/.github/workflows/deploy-cloudflare-workers.yml b/.github/workflows/deploy-cloudflare-workers.yml index b30036cdc..e60b22603 100644 --- a/.github/workflows/deploy-cloudflare-workers.yml +++ b/.github/workflows/deploy-cloudflare-workers.yml @@ -69,17 +69,9 @@ jobs: S3_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_ACCESS_KEY }} S3_ENDPOINT: ${{ secrets.S3_ENDPOINT }} S3_REGION: ${{ secrets.S3_REGION }} - VITE_ENABLE_POSTHOG: 'true' + VITE_ENABLE_ANALYTICS: 'true' VITE_SERVER_URL: 'https://api.airi.build' WARP_DRIVE_PUBLIC_BASE: ${{ secrets.WARP_DRIVE_PUBLIC_BASE }} - - run: | - CI=true pnpm install -g @posthog/cli - pnpm exec posthog-cli sourcemap inject --directory ./apps/stage-web/dist - pnpm exec posthog-cli sourcemap upload --directory ./apps/stage-web/dist - env: - POSTHOG_CLI_ENV_ID: ${{ secrets.POSTHOG_ENV_ID }} - POSTHOG_CLI_TOKEN: ${{ secrets.POSTHOG_TOKEN }} - continue-on-error: true - uses: cloudflare/wrangler-action@v4 with: diff --git a/.github/workflows/deploy-huggingface-spaces.yml b/.github/workflows/deploy-huggingface-spaces.yml index d72327f0e..6ec0e8c08 100644 --- a/.github/workflows/deploy-huggingface-spaces.yml +++ b/.github/workflows/deploy-huggingface-spaces.yml @@ -33,7 +33,7 @@ jobs: env: TARGET_HUGGINGFACE_SPACE: 'true' VITE_APP_TARGET_HUGGINGFACE_SPACE: 'true' - VITE_ENABLE_POSTHOG: 'true' + VITE_ENABLE_ANALYTICS: 'true' - id: airi_diff working-directory: ./apps/stage-web/dist diff --git a/.github/workflows/release-docker.yaml b/.github/workflows/release-docker.yaml index 0f0708817..64f89b91f 100644 --- a/.github/workflows/release-docker.yaml +++ b/.github/workflows/release-docker.yaml @@ -46,7 +46,7 @@ jobs: context: ./ file: ./apps/stage-web/Dockerfile build-args: | - VITE_ENABLE_POSTHOG=true + VITE_ENABLE_ANALYTICS=true platforms: linux/amd64,linux/arm64,linux/arm64/v8 cache-from: type=gha cache-to: type=gha,mode=max diff --git a/.github/workflows/release-pocket-android.yml b/.github/workflows/release-pocket-android.yml index d075147ab..2dc346f16 100644 --- a/.github/workflows/release-pocket-android.yml +++ b/.github/workflows/release-pocket-android.yml @@ -29,7 +29,7 @@ jobs: env: PRODUCT_NAME: 'AIRI' # Every current trigger path produces an installable APK artifact, so ship the analytics-enabled build consistently. - VITE_ENABLE_POSTHOG: 'true' + VITE_ENABLE_ANALYTICS: 'true' steps: - uses: actions/checkout@v6 diff --git a/.github/workflows/release-pocket-ios.yml b/.github/workflows/release-pocket-ios.yml index f46f1e165..1410d2ca6 100644 --- a/.github/workflows/release-pocket-ios.yml +++ b/.github/workflows/release-pocket-ios.yml @@ -26,7 +26,7 @@ jobs: runs-on: macos-26 env: PRODUCT_NAME: 'AIRI' - VITE_ENABLE_POSTHOG: 'true' + VITE_ENABLE_ANALYTICS: 'true' UPLOAD_TESTFLIGHT: ${{ github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && inputs.testflight) }} RELEASE_TAG: ${{ github.event.release.tag_name || inputs.tag }} steps: diff --git a/.github/workflows/release-tamagotchi-steam.yml b/.github/workflows/release-tamagotchi-steam.yml index a0c473ce8..a928300bd 100644 --- a/.github/workflows/release-tamagotchi-steam.yml +++ b/.github/workflows/release-tamagotchi-steam.yml @@ -37,7 +37,7 @@ jobs: VITE_DISTRIBUTION: steam VITE_DISABLE_CUSTOM_PROVIDERS: 'true' VITE_DISABLE_FLUX_PURCHASE: 'true' - VITE_ENABLE_POSTHOG: 'false' + VITE_ENABLE_ANALYTICS: 'false' strategy: fail-fast: false matrix: diff --git a/.github/workflows/release-tamagotchi.yml b/.github/workflows/release-tamagotchi.yml index 061ca5bc0..95d09d79f 100644 --- a/.github/workflows/release-tamagotchi.yml +++ b/.github/workflows/release-tamagotchi.yml @@ -48,7 +48,7 @@ jobs: name: Build continue-on-error: ${{ matrix.skip }} # WORKAROUND: Skipping subsequent steps without failing the whole workflow env: - VITE_ENABLE_POSTHOG: ${{ ((github.event_name == 'release') || (github.event_name == 'workflow_dispatch' && !inputs.build_only)) && 'true' || 'false' }} + VITE_ENABLE_ANALYTICS: ${{ ((github.event_name == 'release') || (github.event_name == 'workflow_dispatch' && !inputs.build_only)) && 'true' || 'false' }} strategy: matrix: include: diff --git a/apps/stage-pocket/package.json b/apps/stage-pocket/package.json index 66cde9958..26f31cbd5 100644 --- a/apps/stage-pocket/package.json +++ b/apps/stage-pocket/package.json @@ -85,7 +85,6 @@ "nprogress": "catalog:", "onnxruntime-web": "catalog:", "pinia": "catalog:", - "posthog-js": "catalog:", "rehype-stringify": "catalog:", "reka-ui": "catalog:", "remark-parse": "catalog:", diff --git a/apps/stage-pocket/src/main.ts b/apps/stage-pocket/src/main.ts index 5a7e5577d..4dd88df26 100644 --- a/apps/stage-pocket/src/main.ts +++ b/apps/stage-pocket/src/main.ts @@ -33,8 +33,8 @@ import './styles/main.css' import 'uno.css' configureAnalyticsAdapter(async (options) => { - const { createPosthogAdapter } = await import('@proj-airi/stage-ui/libs/product-signals/posthog') - return createPosthogAdapter(options) + const { createOpenpanelAdapter } = await import('@proj-airi/stage-ui/libs/product-signals/openpanel') + return createOpenpanelAdapter(options) }) if (Capacitor.isNativePlatform()) { diff --git a/apps/stage-tamagotchi/package.json b/apps/stage-tamagotchi/package.json index 3b2015675..27ddacb8a 100644 --- a/apps/stage-tamagotchi/package.json +++ b/apps/stage-tamagotchi/package.json @@ -120,7 +120,6 @@ "onnxruntime-web": "catalog:", "pinia": "catalog:", "popmotion": "catalog:", - "posthog-js": "catalog:", "rehype-stringify": "catalog:", "reka-ui": "catalog:", "remark-parse": "catalog:", diff --git a/apps/stage-tamagotchi/src/renderer/main.ts b/apps/stage-tamagotchi/src/renderer/main.ts index 0572a6fef..8e1e6d68d 100644 --- a/apps/stage-tamagotchi/src/renderer/main.ts +++ b/apps/stage-tamagotchi/src/renderer/main.ts @@ -42,8 +42,8 @@ import '@fontsource/m-plus-rounded-1c/index.css' import '@fontsource-variable/nunito/index.css' configureAnalyticsAdapter(async (options) => { - const { createPosthogAdapter } = await import('@proj-airi/stage-ui/libs/product-signals/posthog') - return createPosthogAdapter(options) + const { createOpenpanelAdapter } = await import('@proj-airi/stage-ui/libs/product-signals/openpanel') + return createOpenpanelAdapter(options) }) registerAuthorizationHandler(browserAuthorizationHandler) diff --git a/apps/stage-web/Dockerfile b/apps/stage-web/Dockerfile index 05600d42d..3fb763053 100644 --- a/apps/stage-web/Dockerfile +++ b/apps/stage-web/Dockerfile @@ -2,8 +2,8 @@ FROM node:24-trixie AS build-stage WORKDIR /app -ARG VITE_ENABLE_POSTHOG=false -ENV VITE_ENABLE_POSTHOG=${VITE_ENABLE_POSTHOG} +ARG VITE_ENABLE_ANALYTICS=false +ENV VITE_ENABLE_ANALYTICS=${VITE_ENABLE_ANALYTICS} RUN apt update && apt install -y ca-certificates curl RUN update-ca-certificates diff --git a/apps/stage-web/package.json b/apps/stage-web/package.json index f729f87a3..98c32370b 100644 --- a/apps/stage-web/package.json +++ b/apps/stage-web/package.json @@ -83,7 +83,6 @@ "nprogress": "catalog:", "onnxruntime-web": "catalog:", "pinia": "catalog:", - "posthog-js": "catalog:", "rehype-stringify": "catalog:", "reka-ui": "catalog:", "remark-parse": "catalog:", diff --git a/apps/stage-web/src/main.ts b/apps/stage-web/src/main.ts index 82241c4de..4bdf8e08f 100644 --- a/apps/stage-web/src/main.ts +++ b/apps/stage-web/src/main.ts @@ -31,8 +31,8 @@ import './styles/main.css' import 'uno.css' configureAnalyticsAdapter(async (options) => { - const { createPosthogAdapter } = await import('@proj-airi/stage-ui/libs/product-signals/posthog') - return createPosthogAdapter(options) + const { createOpenpanelAdapter } = await import('@proj-airi/stage-ui/libs/product-signals/openpanel') + return createOpenpanelAdapter(options) }) registerAuthorizationHandler(browserAuthorizationHandler) diff --git a/apps/stage-web/src/pages/settings/characters/components/CharacterDialog.vue b/apps/stage-web/src/pages/settings/characters/components/CharacterDialog.vue index 04b5f1382..6b14580ca 100644 --- a/apps/stage-web/src/pages/settings/characters/components/CharacterDialog.vue +++ b/apps/stage-web/src/pages/settings/characters/components/CharacterDialog.vue @@ -163,7 +163,7 @@ async function handleSubmit() { } else { await characterStore.create(payload) - // PostHog retention driver. This dialog is the only user-initiated + // OpenPanel retention driver. This dialog is the only user-initiated // create path; clones from built-in presets would emit // `character_type: 'built_in'` from wherever they get wired up. // `voice_enabled` reflects whether the user supplied a TTS voice id diff --git a/apps/stage-web/vite.config.ts b/apps/stage-web/vite.config.ts index 679631120..2560f0b5c 100644 --- a/apps/stage-web/vite.config.ts +++ b/apps/stage-web/vite.config.ts @@ -97,7 +97,7 @@ export default defineConfig({ chunkFileNames: (chunkInfo) => { const containsAnalyticsModule = chunkInfo.moduleIds.some((moduleId) => { const normalizedModuleId = moduleId.replaceAll('\\', '/').toLowerCase() - return normalizedModuleId.includes('analytics') || normalizedModuleId.includes('posthog') + return normalizedModuleId.includes('analytics') || normalizedModuleId.includes('openpanel') }) // Only analytics/provider chunks receive the manual neutral mapping; diff --git a/apps/ui-server-auth/package.json b/apps/ui-server-auth/package.json index 96f8f4bf7..0415ae152 100644 --- a/apps/ui-server-auth/package.json +++ b/apps/ui-server-auth/package.json @@ -22,6 +22,7 @@ "@formkit/auto-animate": "catalog:", "@moeru/eventa": "catalog:", "@moeru/std": "catalog:", + "@openpanel/web": "catalog:", "@proj-airi/font-chillroundm": "workspace:^", "@proj-airi/font-cjkfonts-allseto": "workspace:^", "@proj-airi/font-xiaolai": "workspace:^", @@ -40,7 +41,6 @@ "nanoid": "catalog:", "nprogress": "catalog:", "pinia": "catalog:", - "posthog-js": "catalog:", "uuid": "catalog:", "vaul-vue": "catalog:", "vue": "catalog:", diff --git a/apps/ui-server-auth/src/main.ts b/apps/ui-server-auth/src/main.ts index 0faedfd8c..0b4b503dd 100644 --- a/apps/ui-server-auth/src/main.ts +++ b/apps/ui-server-auth/src/main.ts @@ -24,10 +24,10 @@ import 'vue-sonner/style.css' import './styles/main.css' import 'uno.css' -if (isEnvTruthy(import.meta.env.VITE_ENABLE_POSTHOG)) { +if (isEnvTruthy(import.meta.env.VITE_ENABLE_ANALYTICS)) { void loadAnalyticsAdapter(async () => { - const { createPosthogAdapter } = await import('./modules/analytics-adapters/posthog') - return createPosthogAdapter() + const { createOpenpanelAdapter } = await import('./modules/analytics-adapters/openpanel') + return createOpenpanelAdapter() }) } diff --git a/apps/ui-server-auth/src/modules/analytics-adapters/openpanel.ts b/apps/ui-server-auth/src/modules/analytics-adapters/openpanel.ts new file mode 100644 index 000000000..0ab5bcf65 --- /dev/null +++ b/apps/ui-server-auth/src/modules/analytics-adapters/openpanel.ts @@ -0,0 +1,34 @@ +import type { AnalyticsAdapter } from '../analytics' + +import { OpenPanel } from '@openpanel/web' +import { OPENPANEL_CONFIG } from '@proj-airi/stage-shared/analytics/openpanel' + +/** Sends auth milestones to the same OpenPanel project as the stage apps. */ +export function createOpenpanelAdapter(): AnalyticsAdapter { + const panel = new OpenPanel({ + ...OPENPANEL_CONFIG, + trackScreenViews: true, + filter(payload) { + // 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 + }, + }) + panel.setGlobalProperties({ app_surface: 'auth' }) + + return { + capture(event, properties) { + // Auth navigation must proceed even when the analytics endpoint fails. + void panel.track(event, properties).catch(() => console.warn('[analytics] Product event delivery failed')) + }, + identify(userId) { + panel.identify({ profileId: userId }) + }, + } +} diff --git a/apps/ui-server-auth/src/modules/analytics-adapters/posthog.ts b/apps/ui-server-auth/src/modules/analytics-adapters/posthog.ts deleted file mode 100644 index 008d185a4..000000000 --- a/apps/ui-server-auth/src/modules/analytics-adapters/posthog.ts +++ /dev/null @@ -1,28 +0,0 @@ -import type { AnalyticsAdapter } from '../analytics' - -import posthog from 'posthog-js' - -import { - DEFAULT_POSTHOG_CONFIG, - POSTHOG_PROJECT_KEY, -} from '@proj-airi/stage-shared/analytics/posthog' - -/** Creates the auth analytics adapter and initializes its provider SDK. */ -export function createPosthogAdapter(): AnalyticsAdapter { - posthog.init(POSTHOG_PROJECT_KEY, { ...DEFAULT_POSTHOG_CONFIG }) - // The shared project distinguishes auth traffic through this super property. - posthog.register({ app_surface: 'auth' }) - - return { - capture(event, properties, options) { - posthog.capture( - event, - properties, - options?.beforeNavigation ? { send_instantly: true, transport: 'sendBeacon' } : undefined, - ) - }, - identify(userId) { - posthog.identify(userId) - }, - } -} diff --git a/apps/ui-server-auth/src/modules/analytics.test.ts b/apps/ui-server-auth/src/modules/analytics.test.ts index 8b958894f..177342d0d 100644 --- a/apps/ui-server-auth/src/modules/analytics.test.ts +++ b/apps/ui-server-auth/src/modules/analytics.test.ts @@ -23,7 +23,7 @@ describe('auth analytics', () => { // // The auth SPA emitted `signup_completed` before it knew the Better Auth // user id, while the server emitted the same canonical event with that id. - // PostHog therefore counted one email signup as two unrelated persons. + // The analytics provider therefore counted one email signup as two unrelated persons. // // The anonymous UI milestone must use its own name. The identified server // event remains the only canonical `signup_completed` business fact. diff --git a/apps/ui-server-auth/vite.config.ts b/apps/ui-server-auth/vite.config.ts index dff7202ac..51c326646 100644 --- a/apps/ui-server-auth/vite.config.ts +++ b/apps/ui-server-auth/vite.config.ts @@ -63,7 +63,7 @@ export default defineConfig({ chunkFileNames: (chunkInfo) => { const containsAnalyticsModule = chunkInfo.moduleIds.some((moduleId) => { const normalizedModuleId = moduleId.replaceAll('\\', '/').toLowerCase() - return normalizedModuleId.includes('analytics') || normalizedModuleId.includes('posthog') + return normalizedModuleId.includes('analytics') || normalizedModuleId.includes('openpanel') }) // Keep analytics as the source-domain name, but explicitly map its diff --git a/deploy/openpanel/README.md b/deploy/openpanel/README.md new file mode 100644 index 000000000..75a5083ac --- /dev/null +++ b/deploy/openpanel/README.md @@ -0,0 +1,83 @@ +# OpenPanel product analytics + +AIRI sends product events to a self-hosted OpenPanel project. Langfuse handles AI tracing through the existing server integration. + +Use this setup for product events, user profiles, and conversion funnels. It does not migrate historical events, HogQL queries, or saved PostHog dashboards. + +## Client configuration + +Use `VITE_ENABLE_ANALYTICS` as the build switch for app analytics. Existing user opt-out behavior remains active. The production docs site records page views separately. + +Client builds use the public API URL and write client id from `packages/stage-shared/src/analytics/openpanel.ts`. +The stage apps and auth UI share this configuration. CI variables and Docker build arguments are not required for these values. + +Never put a client secret in a Vite variable. Allow only the intended application origins on the public OpenPanel client. + +## Server configuration + +Set these variables through the API deployment platform: + +- `OPENPANEL_API_URL` +- `OPENPANEL_CLIENT_ID` +- `OPENPANEL_CLIENT_SECRET` + +Use a separate server write client in the same project. Keep its secret in the deployment platform's secret store. + +All three absent disables server product forwarding. A partial configuration fails startup. + +## Event behavior + +Business event names remain unchanged. OpenPanel uses `screen_view` for automatic page views. Automatic outbound-link capture, attribute capture, and replay remain disabled. + +Stage clients keep a device id in session storage. A reload in the same tab keeps this id. Logout or a consent change rotates it. Anonymous visitor counts therefore do not have the same semantics as PostHog's persistent browser identity. + +Logged-in events use the stable AIRI user id. Checkout sends the device id to the API, which stores it in Stripe metadata. The webhook supplies that device id to OpenPanel. + +Query strings and fragments are removed from automatic page URLs and referrers. This prevents OAuth codes from entering those fields. + +Per-generation analytics events are removed. Completed `message_round` events retain model, token usage, duration, and round identifiers. +`message_sent` retains provider selection. `message_round_failed` retains the failure stage and error code. +Official model tracing, operational metrics, request logs, and billing remain in their existing services. +No client sends prompts or model responses to OpenPanel. + +## Conversion delivery + +The billing transaction suppresses replayed one-time payment conversions. Only a transaction that applies the Flux credit emits the conversion. + +Server delivery makes one request with a five-second timeout. OpenPanel does not provide PostHog's stable UUID deduplication contract. An ambiguous request is not retried. + +Delivery remains best effort. A network failure or process exit can lose a conversion. Use billing records for financial totals. The `event_id` property supports reconciliation. + +## Deployment + +The deployment uses the official `self-hosting` source at commit `cd24bb838301df1a9087e2e132b344e5b8a99963`. + +The stack includes Caddy, PostgreSQL, Redis, ClickHouse, API, Dashboard, and one worker. Persistent volumes hold all database state. + +Deployment adjustments: + +- Generate the PostgreSQL password and cookie secret on the host. +- Require successful migrations before the API starts. +- Create the `openpanel` ClickHouse database before the first API migration. +- Allow 120 seconds for API, Dashboard, and worker startup checks. +- Keep database ports private. +- Keep the proxy on loopback until the domain and TLS configuration are complete. +- Pin the deployed application image digests before later upgrades. + +AWS Systems Manager manages the host. Public SSH is closed. Automatic snapshot management requires a separate approved AWS service role. + +## Release checks + +Before merging and releasing: + +1. Confirm the public domain, DNS, and HTTPS certificate. +2. Confirm the administrator, project, public client, and server client. +3. Configure allowed origins and server secrets. +4. Send a marked browser event and a server event to the deployed API. +5. Confirm both events and their user association in OpenPanel. +6. Verify checkout attribution with a test-mode Stripe payment and a replayed webhook. +7. Recreate the required dashboards and reporting queries. +8. Update the published privacy notice for the final deployment. +9. Confirm backup and restore procedures. + +Retain PostHog history during migration. Reverting this PR restores the old product event path, but it cannot backfill events captured only in OpenPanel. diff --git a/docs/.vitepress/modules/openpanel.ts b/docs/.vitepress/modules/openpanel.ts new file mode 100644 index 000000000..486aae918 --- /dev/null +++ b/docs/.vitepress/modules/openpanel.ts @@ -0,0 +1,23 @@ +import { OpenPanel } from '@openpanel/web' +import { OPENPANEL_CONFIG } from '@proj-airi/stage-shared/analytics/openpanel' + +if (!import.meta.env.DEV) { + const panel = new OpenPanel({ + ...OPENPANEL_CONFIG, + trackScreenViews: true, + trackOutgoingLinks: false, + trackAttributes: false, + filter(payload) { + // Page metadata must not include query values or URL fragments. + 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 + }, + }) + panel.setGlobalProperties({ app_surface: 'docs' }) +} diff --git a/docs/.vitepress/modules/posthog.ts b/docs/.vitepress/modules/posthog.ts deleted file mode 100644 index e9185c1f6..000000000 --- a/docs/.vitepress/modules/posthog.ts +++ /dev/null @@ -1,12 +0,0 @@ -import posthog from 'posthog-js' - -import { DEFAULT_POSTHOG_CONFIG, POSTHOG_PROJECT_KEY } from '@proj-airi/stage-shared/analytics/posthog' - -if (!import.meta.env.DEV) { - posthog.init(POSTHOG_PROJECT_KEY, { - ...DEFAULT_POSTHOG_CONFIG, - }) - // Tag docs-site traffic so it can be told apart from the app surfaces - // inside the shared project. - posthog.register({ app_surface: 'docs' }) -} diff --git a/docs/.vitepress/theme/index.ts b/docs/.vitepress/theme/index.ts index c640a9386..c75a79852 100644 --- a/docs/.vitepress/theme/index.ts +++ b/docs/.vitepress/theme/index.ts @@ -26,7 +26,7 @@ export default { Layout, enhanceApp({ app, siteData }) { if (!import.meta.env.SSR && import.meta.env.PROD) { - import('../modules/posthog') + import('../modules/openpanel') } const i18n = createI18n({ diff --git a/docs/package.json b/docs/package.json index 267e05194..b745924fe 100644 --- a/docs/package.json +++ b/docs/package.json @@ -18,6 +18,7 @@ "@fontsource/dm-mono": "catalog:", "@fontsource/dm-serif-display": "catalog:", "@moeru/std": "catalog:", + "@openpanel/web": "catalog:", "@proj-airi/chromatic": "catalog:", "@proj-airi/i18n": "workspace:^", "@proj-airi/stage-shared": "workspace:^", @@ -28,7 +29,6 @@ "mark.js": "catalog:", "motion-v": "catalog:", "pathe": "catalog:", - "posthog-js": "catalog:", "reka-ui": "catalog:", "vue": "catalog:", "vue-i18n": "catalog:", diff --git a/package.json b/package.json index 2f275c235..194384715 100644 --- a/package.json +++ b/package.json @@ -78,7 +78,6 @@ "oxc-minify": "catalog:", "oxlint": "catalog:", "playwright": "catalog:", - "posthog-js": "catalog:", "publint": "catalog:", "rollup": "catalog:", "simple-git-hooks": "catalog:", diff --git a/packages/stage-layouts/package.json b/packages/stage-layouts/package.json index f2fab5a1b..52f660a39 100644 --- a/packages/stage-layouts/package.json +++ b/packages/stage-layouts/package.json @@ -51,7 +51,6 @@ "nanoid": "catalog:", "node-vibrant": "catalog:", "pinia": "catalog:", - "posthog-js": "catalog:", "reka-ui": "catalog:", "unspeech": "catalog:xsai", "vue": "catalog:", diff --git a/packages/stage-pages/package.json b/packages/stage-pages/package.json index c3ad943ff..b7be85e8b 100644 --- a/packages/stage-pages/package.json +++ b/packages/stage-pages/package.json @@ -47,7 +47,6 @@ "nanoid": "catalog:", "node-vibrant": "catalog:", "pinia": "catalog:", - "posthog-js": "catalog:", "reka-ui": "catalog:", "splitpanes": "catalog:", "unspeech": "catalog:xsai", diff --git a/packages/stage-pages/src/pages/settings/flux.vue b/packages/stage-pages/src/pages/settings/flux.vue index fecf51f57..ac3c5b7bc 100644 --- a/packages/stage-pages/src/pages/settings/flux.vue +++ b/packages/stage-pages/src/pages/settings/flux.vue @@ -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', diff --git a/packages/stage-shared/package.json b/packages/stage-shared/package.json index fd9bd5cc4..379c8eb06 100644 --- a/packages/stage-shared/package.json +++ b/packages/stage-shared/package.json @@ -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:" }, diff --git a/packages/stage-shared/src/analytics/openpanel.ts b/packages/stage-shared/src/analytics/openpanel.ts new file mode 100644 index 000000000..2ad2be95a --- /dev/null +++ b/packages/stage-shared/src/analytics/openpanel.ts @@ -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 diff --git a/packages/stage-shared/src/analytics/posthog.ts b/packages/stage-shared/src/analytics/posthog.ts deleted file mode 100644 index 09089adcb..000000000 --- a/packages/stage-shared/src/analytics/posthog.ts +++ /dev/null @@ -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 diff --git a/packages/stage-ui/package.json b/packages/stage-ui/package.json index 9a259c636..4689a3b9f 100644 --- a/packages/stage-ui/package.json +++ b/packages/stage-ui/package.json @@ -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:", diff --git a/packages/stage-ui/src/composables/use-analytics.test.ts b/packages/stage-ui/src/composables/use-analytics.test.ts index 2d7f9ce56..7b3c94c3b 100644 --- a/packages/stage-ui/src/composables/use-analytics.test.ts +++ b/packages/stage-ui/src/composables/use-analytics.test.ts @@ -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', {}) }) }) diff --git a/packages/stage-ui/src/composables/use-analytics.ts b/packages/stage-ui/src/composables/use-analytics.ts index 8045c32d5..d1a86a547 100644 --- a/packages/stage-ui/src/composables/use-analytics.ts +++ b/packages/stage-ui/src/composables/use-analytics.ts @@ -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, diff --git a/packages/stage-ui/src/libs/auth-fetch.test.ts b/packages/stage-ui/src/libs/auth-fetch.test.ts index 0e6dead85..09bd0e9fd 100644 --- a/packages/stage-ui/src/libs/auth-fetch.test.ts +++ b/packages/stage-ui/src/libs/auth-fetch.test.ts @@ -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>(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>(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>(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() }) }) diff --git a/packages/stage-ui/src/libs/auth-fetch.ts b/packages/stage-ui/src/libs/auth-fetch.ts index 622990347..95dbdfcf9 100644 --- a/packages/stage-ui/src/libs/auth-fetch.ts +++ b/packages/stage-ui/src/libs/auth-fetch.ts @@ -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 diff --git a/packages/stage-ui/src/libs/product-signals/client.ts b/packages/stage-ui/src/libs/product-signals/client.ts index 283f68750..e32c11115 100644 --- a/packages/stage-ui/src/libs/product-signals/client.ts +++ b/packages/stage-ui/src/libs/product-signals/client.ts @@ -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 { diff --git a/packages/stage-ui/src/libs/product-signals/events/chat/events/generation.ts b/packages/stage-ui/src/libs/product-signals/events/chat/events/generation.ts deleted file mode 100644 index ddc64ecfe..000000000 --- a/packages/stage-ui/src/libs/product-signals/events/chat/events/generation.ts +++ /dev/null @@ -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') diff --git a/packages/stage-ui/src/libs/product-signals/events/chat/events/index.ts b/packages/stage-ui/src/libs/product-signals/events/chat/events/index.ts index 1ae5b4a0f..a339e50e3 100644 --- a/packages/stage-ui/src/libs/product-signals/events/chat/events/index.ts +++ b/packages/stage-ui/src/libs/product-signals/events/chat/events/index.ts @@ -1,3 +1,2 @@ -export * from './generation' export * from './message' export * from './round' diff --git a/packages/stage-ui/src/libs/product-signals/events/chat/runtime.test.ts b/packages/stage-ui/src/libs/product-signals/events/chat/runtime.test.ts index 4be884412..aa7954260 100644 --- a/packages/stage-ui/src/libs/product-signals/events/chat/runtime.test.ts +++ b/packages/stage-ui/src/libs/product-signals/events/chat/runtime.test.ts @@ -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', + })) }) }) diff --git a/packages/stage-ui/src/libs/product-signals/events/chat/runtime.ts b/packages/stage-ui/src/libs/product-signals/events/chat/runtime.ts index 05e56d503..2f91520d1 100644 --- a/packages/stage-ui/src/libs/product-signals/events/chat/runtime.ts +++ b/packages/stage-ui/src/libs/product-signals/events/chat/runtime.ts @@ -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, diff --git a/packages/stage-ui/src/libs/product-signals/openpanel.browser.test.ts b/packages/stage-ui/src/libs/product-signals/openpanel.browser.test.ts new file mode 100644 index 000000000..5826a80fd --- /dev/null +++ b/packages/stage-ui/src/libs/product-signals/openpanel.browser.test.ts @@ -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) + }) +}) diff --git a/packages/stage-ui/src/libs/product-signals/openpanel.ts b/packages/stage-ui/src/libs/product-signals/openpanel.ts new file mode 100644 index 000000000..337a90a06 --- /dev/null +++ b/packages/stage-ui/src/libs/product-signals/openpanel.ts @@ -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 + }, + } +} diff --git a/packages/stage-ui/src/libs/product-signals/posthog.test.ts b/packages/stage-ui/src/libs/product-signals/posthog.test.ts deleted file mode 100644 index ad5efc0f2..000000000 --- a/packages/stage-ui/src/libs/product-signals/posthog.test.ts +++ /dev/null @@ -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' }, - ) - }) -}) diff --git a/packages/stage-ui/src/libs/product-signals/posthog.ts b/packages/stage-ui/src/libs/product-signals/posthog.ts deleted file mode 100644 index 7fefd65bb..000000000 --- a/packages/stage-ui/src/libs/product-signals/posthog.ts +++ /dev/null @@ -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' -} diff --git a/packages/stage-ui/src/stores/chat.contract.test.ts b/packages/stage-ui/src/stores/chat.contract.test.ts index e1ae27a7d..aeaaefcc0 100644 --- a/packages/stage-ui/src/stores/chat.contract.test.ts +++ b/packages/stage-ui/src/stores/chat.contract.test.ts @@ -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) => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 94e90d4c7..50364b02b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -264,6 +264,12 @@ catalogs: '@nekopaw/tempora': specifier: 0.4.0-alpha.1 version: 0.4.0-alpha.1 + '@openpanel/sdk': + specifier: 1.3.1 + version: 1.3.1 + '@openpanel/web': + specifier: 1.4.1 + version: 1.4.1 '@opentelemetry/api': specifier: ^1.9.1 version: 1.9.1 @@ -927,12 +933,6 @@ catalogs: postgres: specifier: ^3.4.9 version: 3.4.9 - posthog-js: - specifier: 1.306.1 - version: 1.306.1 - posthog-node: - specifier: ^5.50.0 - version: 5.50.0 postprocessing: specifier: ^6.39.4 version: 6.39.4 @@ -1329,9 +1329,6 @@ importers: playwright: specifier: 'catalog:' version: 1.62.1 - posthog-js: - specifier: 'catalog:' - version: 1.306.1 publint: specifier: 'catalog:' version: 0.3.24 @@ -1648,9 +1645,6 @@ importers: pinia: specifier: 'catalog:' version: 4.0.3(@vue/devtools-api@8.2.1)(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3)) - posthog-js: - specifier: 'catalog:' - version: 1.306.1 rehype-stringify: specifier: 'catalog:' version: 10.0.1 @@ -2093,9 +2087,6 @@ importers: popmotion: specifier: 'catalog:' version: 11.0.5 - posthog-js: - specifier: 'catalog:' - version: 1.306.1 rehype-stringify: specifier: 'catalog:' version: 10.0.1 @@ -2569,9 +2560,6 @@ importers: pinia: specifier: 'catalog:' version: 4.0.3(@vue/devtools-api@8.2.1)(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3)) - posthog-js: - specifier: 'catalog:' - version: 1.306.1 rehype-stringify: specifier: 'catalog:' version: 10.0.1 @@ -2783,6 +2771,9 @@ importers: '@moeru/std': specifier: 'catalog:' version: 0.1.0-beta.17 + '@openpanel/web': + specifier: 'catalog:' + version: 1.4.1 '@proj-airi/font-chillroundm': specifier: workspace:^ version: link:../../packages/font-chillroundm @@ -2837,9 +2828,6 @@ importers: pinia: specifier: 'catalog:' version: 4.0.3(@vue/devtools-api@8.2.1)(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3)) - posthog-js: - specifier: 'catalog:' - version: 1.306.1 uuid: specifier: 'catalog:' version: 14.0.2 @@ -2970,6 +2958,9 @@ importers: '@moeru/std': specifier: 'catalog:' version: 0.1.0-beta.17 + '@openpanel/web': + specifier: 'catalog:' + version: 1.4.1 '@proj-airi/chromatic': specifier: 'catalog:' version: 1.1.4 @@ -3000,9 +2991,6 @@ importers: pathe: specifier: 'catalog:' version: 2.0.3 - posthog-js: - specifier: 'catalog:' - version: 1.306.1 reka-ui: specifier: 'catalog:' version: 2.10.3(vue@3.5.41(typescript@6.0.3)) @@ -4149,9 +4137,6 @@ importers: pinia: specifier: 'catalog:' version: 4.0.3(@vue/devtools-api@8.2.1)(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3)) - posthog-js: - specifier: 'catalog:' - version: 1.306.1 reka-ui: specifier: 'catalog:' version: 2.10.3(vue@3.5.41(typescript@6.0.3)) @@ -4279,9 +4264,6 @@ importers: pinia: specifier: 'catalog:' version: 4.0.3(@vue/devtools-api@8.2.1)(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3)) - posthog-js: - specifier: 'catalog:' - version: 1.306.1 reka-ui: specifier: 'catalog:' version: 2.10.3(vue@3.5.41(typescript@6.0.3)) @@ -4349,9 +4331,6 @@ importers: pinia: specifier: 'catalog:' version: 4.0.3(@vue/devtools-api@8.2.1)(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3)) - posthog-js: - specifier: 'catalog:' - version: 1.306.1 valibot: specifier: 'catalog:' version: 1.4.2(typescript@6.0.3) @@ -4395,6 +4374,9 @@ importers: '@moeru/std': specifier: 'catalog:' version: 0.1.0-beta.17 + '@openpanel/web': + specifier: 'catalog:' + version: 1.4.1 '@opentelemetry/api': specifier: 'catalog:' version: 1.9.1 @@ -4620,9 +4602,6 @@ importers: pinia-plugin-synced: specifier: 'catalog:' version: 0.1.4(pinia@4.0.3(@vue/devtools-api@8.2.1)(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3)))(vue@3.5.41(typescript@6.0.3)) - posthog-js: - specifier: 'catalog:' - version: 1.306.1 rehype-katex: specifier: 'catalog:' version: 7.0.1 @@ -5568,6 +5547,9 @@ importers: '@moeru/std': specifier: 'catalog:' version: 0.1.0-beta.17 + '@openpanel/sdk': + specifier: 'catalog:' + version: 1.3.1 '@opentelemetry/api': specifier: 'catalog:' version: 1.9.1 @@ -5658,9 +5640,6 @@ importers: pg: specifier: 'catalog:' version: 8.23.0 - posthog-node: - specifier: 'catalog:' - version: 5.50.0(rxjs@7.8.2) stripe: specifier: 'catalog:' version: 22.5.0(@types/node@26.2.0) @@ -8658,6 +8637,12 @@ packages: '@one-ini/wasm@0.1.1': resolution: {integrity: sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==} + '@openpanel/sdk@1.3.1': + resolution: {integrity: sha512-mQ5xaBUGXnyRg3qYxwnbVaUYyW7w8u+munrWk/ClnqxEeR2j+FYPiA6noHbHMCFC2aZutYD5OoRzEvw3FBNFxw==} + + '@openpanel/web@1.4.1': + resolution: {integrity: sha512-DgUWeocZw+b57YiJjefZPg+6sceR7TdOF8x7Ut75lu3CwcwqGi5jZNPXjikNemGL37aNWXCB4buhcoNan2j7RA==} + '@opentelemetry/api-logs@0.221.0': resolution: {integrity: sha512-OlanaW1vv7ufTqQ3/fPLI4arGt5ZoM+P8abOMki6uEYnpRazepSWDwDnnw+la7kE26SHVC18//SMccrDvLKOXQ==} engines: {node: '>=8.0.0'} @@ -10545,15 +10530,6 @@ packages: '@polka/url@1.0.0-next.29': resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} - '@posthog/core@1.48.8': - resolution: {integrity: sha512-LAOBOjMQrQmgcbZxnubl74l2GQd5OWqxCJJ8mzcWdZRWbfO6Y/A6E3fqWjDtm68hY4LxC/bbopG60cxZW8cfWw==} - - '@posthog/core@1.7.1': - resolution: {integrity: sha512-kjK0eFMIpKo9GXIbts8VtAknsoZ18oZorANdtuTj1CbgS28t4ZVq//HAWhnxEuXRTrtkd+SUJ6Ux3j2Af8NCuA==} - - '@posthog/types@1.405.1': - resolution: {integrity: sha512-JvaR4ChUKUk7qSTG58vKN2Br6es9riFF5mvlu7YGcwbB476vfyTO0o/TBh+zE88QhsfxnE5Tr/jKxI+e1v3Q5A==} - '@prisma/client@5.22.0': resolution: {integrity: sha512-M0SVXfyHnQREBKxCgyo7sffrKttwE6R8PMq330MIUF0pTwjUhLbW84pFDlf06B27XyCR++VtjugEnIHdr07SVA==} engines: {node: '>=16.13'} @@ -10930,6 +10906,12 @@ packages: cpu: [x64] os: [win32] + '@rrweb/types@2.0.0-alpha.20': + resolution: {integrity: sha512-RbnDgKxA/odwB1R4gF7eUUj+rdSrq6ROQJsnMw7MIsGzlbSYvJeZN8YY4XqU0G6sKJvXI6bSzk7w/G94jNwzhw==} + + '@rrweb/utils@2.1.1': + resolution: {integrity: sha512-x2SgJAD3YJ9eVcLZ5l6OqWMtczdA3VfS5XqPeqpZdQgV9oh6Id5GK2cDN4bimnkqryOcIJdz8cTvV0yNvqQ+nA==} + '@sapphire/async-queue@1.5.5': resolution: {integrity: sha512-cvGzxbba6sav2zZkH8GPf2oGk9yYoD5qrNWdu9fRehifgnFZJMV+nuy2nON2roRO4yQQ+v7MK/Pktl/HgfsUXg==} engines: {node: '>=v14.0.0', npm: '>=7.0.0'} @@ -11309,6 +11291,9 @@ packages: '@types/cors@2.8.19': resolution: {integrity: sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==} + '@types/css-font-loading-module@0.0.7': + resolution: {integrity: sha512-nl09VhutdjINdWyXxHWN/w9zlNCfr60JUqJbd24YXUuCwgeL0TpFSdElCwb6cxfB6ybE19Gjj4g0jsgkXxKv1Q==} + '@types/culori@4.0.1': resolution: {integrity: sha512-43M51r/22CjhbOXyGT361GZ9vncSVQ39u62x5eJdBQFviI8zWp2X5jzqg7k4M6PVgDQAClpy2bUe2dtwEgEDVQ==} @@ -12431,6 +12416,9 @@ packages: '@xsai/utils-chat@0.5.0': resolution: {integrity: sha512-z6HZHngL1q9Uasc8L0uQkeGXc7UzqY5LSTc2eYW7EjEgFK4WjGt7iTo0TM+p/ruoQHKy8AAigMkmUQcuGvwPlA==} + '@xstate/fsm@1.6.5': + resolution: {integrity: sha512-b5o1I6aLNeYlU/3CPlj/Z91ybk1gUsKT+5NAJI+2W4UjvS5KLG28K9v5UvNoFVjHV8PajVZ00RH3vnjyQO7ZAw==} + '@yuku-codegen/binding-android-arm64@0.8.7': resolution: {integrity: sha512-C/0zV5IhgVdYhGJTwrY0v8dknxlhiKwtVJkMUaexu9/QvRmzlV4vfU3hZlUSgqc2BxQHntL1mCVbDq8j0FRFDw==} cpu: [arm64] @@ -13525,9 +13513,6 @@ packages: resolution: {integrity: sha512-XGpFGbMLHwSt74YLTKho7Ib242qi6O8MSX+sRokV4oz7iKXvQWGYZthjIhjRGMxjzVkAubBO512dKGYcefmX3Q==} engines: {node: '>=6.4.0'} - core-js@3.50.0: - resolution: {integrity: sha512-BRWgOLKkFeCgRudR6zrs8p9XJZcE14grzKMMssoYrk6krtuEZ7MTKPIY5RzOnqsEKIR9kst7wNzphttraT+Yqw==} - core-util-is@1.0.2: resolution: {integrity: sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==} @@ -14854,9 +14839,6 @@ packages: resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} engines: {node: ^12.20 || >= 14.13} - fflate@0.4.9: - resolution: {integrity: sha512-zdxgIEddhfsyCaWpJ2SdXEP8ZMrKJ6+5jl4OupODcywU0IhRk6gdXuVGcPICyfx2H97hVK7xmJtRLPjkxAX8Vw==} - fflate@0.6.11: resolution: {integrity: sha512-3JyEFWGjFn7zHmoa9+zG1BmW7X2okcmAB+0Cnu9UFbVs/jCBnl2A8o065ZlXiw145K3eBM3uLuzrYXC0RK7eDg==} @@ -16548,6 +16530,9 @@ packages: resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==} engines: {node: '>= 18'} + mitt@3.0.1: + resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==} + mkcert@3.2.0: resolution: {integrity: sha512-026Eivq9RoOjOuLJGzbhGwXUAjBxRX11Z7Jbm4/7lqT/Av+XNy9SPrJte6+UpEt7i+W3e/HZYxQqlQcqXZWSzg==} engines: {node: '>=16'} @@ -17266,18 +17251,6 @@ packages: resolution: {integrity: sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw==} engines: {node: '>=12'} - posthog-js@1.306.1: - resolution: {integrity: sha512-wO7bliv/5tlAlfoKCUzwkGXZVNexk0dHigMf9tNp0q1rzs62wThogREY7Tz7h/iWKYiuXy1RumtVlTmHuBXa1w==} - - posthog-node@5.50.0: - resolution: {integrity: sha512-7trcTN4EcPBiYAI1nnD1rk+VB+OcjgN0Mdnp7kOK09PTCSW0LCAUVAYj3FD7u8Ne5zYCnW/ESv6k3VU7sOsL5w==} - engines: {node: ^20.20.0 || >=22.22.0} - peerDependencies: - rxjs: ^7.0.0 - peerDependenciesMeta: - rxjs: - optional: true - postject@1.0.0-alpha.6: resolution: {integrity: sha512-b9Eb8h2eVqNE8edvKdwqkrY6O7kAwmI8kcnBv1NScolYJbo59XUF0noFq+lxbC1yN20bmC0WBEbDC5H/7ASb0A==} engines: {node: '>=14.0.0'} @@ -17299,14 +17272,6 @@ packages: resolution: {integrity: sha512-ZlsFlG7MtSFCoc5xreOvBAozCJ6Pf06opgJjh9ONEv418xpZSAzNjstD36C6+JwOnfSqOW/9uDkqKjezTdxZhw==} engines: {node: '>=20'} - preact@10.29.8: - resolution: {integrity: sha512-ej2aVZ+vZ8WO7tvlQWRM9N63A0KzF9q4mWJfDUHgYaIofWY9hu74QdnQrjoPMmZi2/nZ5gN0bJCQF49xQqx09Q==} - peerDependencies: - preact-render-to-string: '>=5' - peerDependenciesMeta: - preact-render-to-string: - optional: true - prebuild-install@7.1.3: resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==} engines: {node: '>=10'} @@ -17845,6 +17810,15 @@ packages: resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} engines: {node: '>= 18'} + rrdom@2.1.1: + resolution: {integrity: sha512-VBkTF3bGNqcZjqnbo/gKi9GVQPIxiG0dofw7CQdAZQFQ2juJdt2YKIf3oS9QudL8HTpGh9bz5TUN+3amBn1Geg==} + + rrweb-snapshot@2.1.1: + resolution: {integrity: sha512-al4Kx7Am7YlIn/5Yb2EMr7cdmjGiK+cLhdilJEXqkFXgpnys8t/yMJumXy2Ormgirpg3MBnFha0GTgny+iIL2w==} + + rrweb@2.0.0-alpha.20: + resolution: {integrity: sha512-CZKDlm+j1VA50Ko3gnMbpvguCAleljsTNXPnVk9aeNP8o6T6kolRbISHyDZpqZ4G+bdDLlQOignPP3jEsXs8Gg==} + run-applescript@7.1.0: resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==} engines: {node: '>=18'} @@ -19600,9 +19574,6 @@ packages: resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} engines: {node: '>= 8'} - web-vitals@4.2.4: - resolution: {integrity: sha512-r4DIlprAGwJ7YM11VZp4R884m0Vmgr6EAKe3P+kO0PPj3Unqyvv59rczf6UiGcb9Z8QxZVcqKNwv/g0WNdWwsw==} - webcrypto-core@1.9.2: resolution: {integrity: sha512-gsXecm82UQNlTBURJGuqOWy1Ww08S3kZUcr3aOJS02Pk0xLtkfeUAVC0u0xhgdonFme80edSJUIJyuvL/7250Q==} @@ -23109,6 +23080,14 @@ snapshots: '@one-ini/wasm@0.1.1': {} + '@openpanel/sdk@1.3.1': {} + + '@openpanel/web@1.4.1': + dependencies: + '@openpanel/sdk': 1.3.1 + '@rrweb/types': 2.0.0-alpha.20 + rrweb: 2.0.0-alpha.20 + '@opentelemetry/api-logs@0.221.0': dependencies: '@opentelemetry/api': 1.9.1 @@ -24674,16 +24653,6 @@ snapshots: '@polka/url@1.0.0-next.29': {} - '@posthog/core@1.48.8': - dependencies: - '@posthog/types': 1.405.1 - - '@posthog/core@1.7.1': - dependencies: - cross-spawn: 7.0.6 - - '@posthog/types@1.405.1': {} - '@prisma/client@5.22.0': {} '@proj-airi/chromatic@1.1.4': @@ -24947,6 +24916,10 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.62.5': optional: true + '@rrweb/types@2.0.0-alpha.20': {} + + '@rrweb/utils@2.1.1': {} + '@sapphire/async-queue@1.5.5': {} '@sapphire/shapeshift@4.0.0': @@ -25309,6 +25282,8 @@ snapshots: dependencies: '@types/node': 26.2.0 + '@types/css-font-loading-module@0.0.7': {} + '@types/culori@4.0.1': {} '@types/d3-array@3.2.2': {} @@ -26910,6 +26885,8 @@ snapshots: dependencies: '@xsai/shared-chat': 0.5.0 + '@xstate/fsm@1.6.5': {} + '@yuku-codegen/binding-android-arm64@0.8.7': optional: true @@ -27888,8 +27865,6 @@ snapshots: dependencies: browserslist: 4.28.8 - core-js@3.50.0: {} - core-util-is@1.0.2: {} core-util-is@1.0.3: {} @@ -29347,8 +29322,6 @@ snapshots: node-domexception: 1.0.0 web-streams-polyfill: 3.3.3 - fflate@0.4.9: {} - fflate@0.6.11: {} fflate@0.8.3: {} @@ -31557,6 +31530,8 @@ snapshots: dependencies: minipass: 7.1.3 + mitt@3.0.1: {} + mkcert@3.2.0: dependencies: commander: 11.1.0 @@ -32429,22 +32404,6 @@ snapshots: postgres@3.4.9: {} - posthog-js@1.306.1: - dependencies: - '@posthog/core': 1.7.1 - core-js: 3.50.0 - fflate: 0.4.9 - preact: 10.29.8 - web-vitals: 4.2.4 - transitivePeerDependencies: - - preact-render-to-string - - posthog-node@5.50.0(rxjs@7.8.2): - dependencies: - '@posthog/core': 1.48.8 - optionalDependencies: - rxjs: 7.8.2 - postject@1.0.0-alpha.6: dependencies: commander: 9.5.0 @@ -32460,8 +32419,6 @@ snapshots: powershell-utils@0.2.0: {} - preact@10.29.8: {} - prebuild-install@7.1.3: dependencies: detect-libc: 2.1.2 @@ -33174,6 +33131,25 @@ snapshots: transitivePeerDependencies: - supports-color + rrdom@2.1.1: + dependencies: + rrweb-snapshot: 2.1.1 + + rrweb-snapshot@2.1.1: + dependencies: + postcss: 8.5.26 + + rrweb@2.0.0-alpha.20: + dependencies: + '@rrweb/types': 2.0.0-alpha.20 + '@rrweb/utils': 2.1.1 + '@types/css-font-loading-module': 0.0.7 + '@xstate/fsm': 1.6.5 + base64-arraybuffer: 1.0.2 + mitt: 3.0.1 + rrdom: 2.1.1 + rrweb-snapshot: 2.1.1 + run-applescript@7.1.0: {} run-parallel@1.2.0: @@ -35077,8 +35053,6 @@ snapshots: web-streams-polyfill@3.3.3: {} - web-vitals@4.2.4: {} - webcrypto-core@1.9.2: dependencies: '@peculiar/asn1-schema': 2.9.4 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 382e38579..e9a580a2d 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -129,6 +129,8 @@ catalog: '@moeru/three-mmd-physics-ammo': 0.2.0-beta.2 '@napi-rs/image': ^1.14.0 '@nekopaw/tempora': 0.4.0-alpha.1 + '@openpanel/sdk': 1.3.1 + '@openpanel/web': 1.4.1 '@opentelemetry/api': ^1.9.1 '@opentelemetry/api-logs': ^0.221.0 '@opentelemetry/auto-instrumentations-node': ^0.79.0 @@ -353,8 +355,6 @@ catalog: popmotion: ^11.0.5 postcss: ^8.5.26 postgres: ^3.4.9 - posthog-js: 1.306.1 - posthog-node: ^5.50.0 postprocessing: ^6.39.4 prismarine-block: ^1.23.0 prismarine-entity: ^2.6.0 diff --git a/server/apps/api/package.json b/server/apps/api/package.json index 6d546cd60..43bfb7544 100644 --- a/server/apps/api/package.json +++ b/server/apps/api/package.json @@ -27,6 +27,7 @@ "@langfuse/tracing": "catalog:", "@moeru/eventa": "catalog:", "@moeru/std": "catalog:", + "@openpanel/sdk": "catalog:", "@opentelemetry/api": "catalog:", "@opentelemetry/api-logs": "catalog:", "@opentelemetry/exporter-logs-otlp-proto": "catalog:", @@ -57,7 +58,6 @@ "nanoid": "catalog:", "ofetch": "catalog:", "pg": "catalog:", - "posthog-node": "catalog:", "stripe": "catalog:", "unspeech": "catalog:xsai", "valibot": "catalog:", diff --git a/server/apps/api/src/app.test.ts b/server/apps/api/src/app.test.ts index c3a11c8d0..f63c2948a 100644 --- a/server/apps/api/src/app.test.ts +++ b/server/apps/api/src/app.test.ts @@ -28,7 +28,6 @@ function createTestDeps() { providerCatalogService: {} as never, productEventService: { track: vi.fn(async () => undefined), - trackGeneration: vi.fn(async () => undefined), } as never, configKV: { getOrThrow: vi.fn() } as never, redis: redis as never, diff --git a/server/apps/api/src/app.ts b/server/apps/api/src/app.ts index f3397bdb3..eede410a0 100644 --- a/server/apps/api/src/app.ts +++ b/server/apps/api/src/app.ts @@ -60,7 +60,7 @@ import { createStripeRoutes } from './routes/stripe' import { createVoicePackRoutes } from './routes/voice-packs' import { createConfigKVService } from './services/adapters/config-kv' import { createConfigKVStore } from './services/adapters/config-kv/store' -import { createPosthogSink } from './services/adapters/posthog' +import { createOpenpanelSink } from './services/adapters/openpanel' import { createBillingService } from './services/domain/billing/billing-service' import { createFluxMeter } from './services/domain/billing/flux-meter' import { createCharacterService } from './services/domain/characters' @@ -529,27 +529,21 @@ export async function createApp() { build: ({ dependsOn }) => createConfigKVService(createConfigKVStore(dependsOn.db, dependsOn.redis)), }) - const posthogSink = injeca.provide('services:posthogSink', { - dependsOn: { env: parsedEnv, lifecycle }, - // POSTHOG_PROJECT_KEY defaults to the shared project key, so the falsy - // branch is only reachable via the documented off-switch: setting the - // env var to an empty string (valibot defaults don't apply to ''). + const openpanelSink = injeca.provide('services:openpanelSink', { + dependsOn: { env: parsedEnv }, build: ({ dependsOn }) => { - if (!dependsOn.env.POSTHOG_PROJECT_KEY) + const { OPENPANEL_API_URL: apiUrl, OPENPANEL_CLIENT_ID: clientId, OPENPANEL_CLIENT_SECRET: clientSecret } = dependsOn.env + if (!apiUrl && !clientId && !clientSecret) return null - - const sink = createPosthogSink({ - projectKey: dependsOn.env.POSTHOG_PROJECT_KEY, - host: dependsOn.env.POSTHOG_API_HOST, - }) - dependsOn.lifecycle.appHooks.onStop(() => sink.shutdown()) - return sink + if (!apiUrl || !clientId || !clientSecret) + throw new Error('OpenPanel requires API URL, client id, and client secret') + return createOpenpanelSink({ apiUrl, clientId, clientSecret }) }, }) const productEventService = injeca.provide('services:productEvents', { - dependsOn: { posthogSink }, - build: ({ dependsOn }) => createProductEventService(dependsOn.posthogSink), + dependsOn: { openpanelSink }, + build: ({ dependsOn }) => createProductEventService(dependsOn.openpanelSink), }) const characterService = injeca.provide('services:characters', { diff --git a/server/apps/api/src/libs/env.ts b/server/apps/api/src/libs/env.ts index f3b4cf978..696696194 100644 --- a/server/apps/api/src/libs/env.ts +++ b/server/apps/api/src/libs/env.ts @@ -104,13 +104,9 @@ const EnvSchema = object({ OTEL_TRACES_SAMPLING_RATIO: optionalNumberFromString(1, 'OTEL_TRACES_SAMPLING_RATIO', 0, 1), PORT: optionalIntegerFromString(3000, 'PORT', 1), - POSTHOG_API_HOST: optional(string(), 'https://t.airi.build'), - // PostHog product-event forwarding for server-confirmed funnel facts. - // Defaults to the shared AIRI project key (same browser-safe phc_* key the - // client surfaces embed in stage-shared/analytics/posthog), so forwarding is on out of - // the box. Set to an empty string to disable server-side product analytics. - POSTHOG_PROJECT_KEY: optional(string(), 'phc_pzjziJjrVZpa9SqnQqq0QEKvkmuCPH7GDTA6TbRTEf9'), // cspell:disable-line - + OPENPANEL_API_URL: optional(string()), + OPENPANEL_CLIENT_ID: optional(string()), + OPENPANEL_CLIENT_SECRET: optional(string()), REDIS_URL: pipe(string(), nonEmpty('REDIS_URL is required')), STRIPE_SECRET_KEY: optional(string()), diff --git a/server/apps/api/src/routes/openai/v1/analytics.ts b/server/apps/api/src/routes/openai/v1/analytics.ts index 85aca5290..5bdd5d3af 100644 --- a/server/apps/api/src/routes/openai/v1/analytics.ts +++ b/server/apps/api/src/routes/openai/v1/analytics.ts @@ -1,21 +1,20 @@ -import type { AiGenerationAppSurface } from '../../../services/domain/product-events' +/** Client runtime declared by the chat request header. */ +export type ChatAppSurface = 'web' | 'mobile' | 'electron' export const AIRI_CHAT_SESSION_ID_HEADER = 'x-airi-session-id' export const AIRI_CHAT_ROUND_ID_HEADER = 'x-airi-round-id' export const AIRI_CHAT_APP_SURFACE_HEADER = 'x-airi-app-surface' -const CLIENT_CHAT_ANALYTICS_SURFACES = new Set(['web', 'mobile', 'electron']) +const CLIENT_CHAT_ANALYTICS_SURFACES = new Set(['web', 'mobile', 'electron']) /** * Resolves the product runtime from a trusted client hint. * - * Unknown values are not coerced to `server`: `$ai_generation` uses - * `capture_surface` for the process that emitted the event, while - * `app_surface` stays reserved for the user's actual product runtime. + * Unknown values stay absent rather than being attributed to a client runtime. */ -export function resolveChatAnalyticsSurface(value: string | undefined): AiGenerationAppSurface | undefined { - if (CLIENT_CHAT_ANALYTICS_SURFACES.has(value as AiGenerationAppSurface)) - return value as AiGenerationAppSurface +export function resolveChatAnalyticsSurface(value: string | undefined): ChatAppSurface | undefined { + if (CLIENT_CHAT_ANALYTICS_SURFACES.has(value as ChatAppSurface)) + return value as ChatAppSurface return undefined } diff --git a/server/apps/api/src/routes/openai/v1/operations/chat-completions/index.ts b/server/apps/api/src/routes/openai/v1/operations/chat-completions/index.ts index 559df1dfa..3378e7dde 100644 --- a/server/apps/api/src/routes/openai/v1/operations/chat-completions/index.ts +++ b/server/apps/api/src/routes/openai/v1/operations/chat-completions/index.ts @@ -1,6 +1,6 @@ import type { CapabilityAliasRoute } from '../../../../../schemas/provider-catalog' import type { UsageInfo } from '../../../../../services/domain/billing/billing' -import type { AiGenerationAppSurface } from '../../../../../services/domain/product-events' +import type { ChatAppSurface } from '../../analytics' import type { GatewayCallback } from '../../gateway' import type { V1RouteDeps } from '../../types' @@ -22,24 +22,10 @@ export interface ChatCompletionsOperationRequest { body: Record sessionId?: string roundId?: string - appSurface?: AiGenerationAppSurface + appSurface?: ChatAppSurface abortSignal?: AbortSignal } -interface GenerationCaptureInput { - deps: V1RouteDeps - userId: string - requestId: string - sessionId?: string - roundId?: string - appSurface?: AiGenerationAppSurface - generationModel: string - routeCtxProvider: string - usage: UsageInfo - durationMs: number - stream: boolean -} - export function chatCompletions(deps: V1RouteDeps): GatewayCallback<'chat.completions'> { const logger = useLogger('v1-completions').useGlobalConfig() const telemetry = createRouteTelemetry({ @@ -157,11 +143,7 @@ export function chatCompletions(deps: V1RouteDeps): GatewayCallback<'chat.comple durationMs, requestId, userId: input.userId, - sessionId: input.sessionId, - roundId: input.roundId, - appSurface: input.appSurface, requestModel, - generationModel: langfuseModel, routeCtxProvider: routeCtx.provider, billing, billingPolicy, @@ -178,11 +160,7 @@ export function chatCompletions(deps: V1RouteDeps): GatewayCallback<'chat.comple durationMs, requestId, userId: input.userId, - sessionId: input.sessionId, - roundId: input.roundId, - appSurface: input.appSurface, requestModel, - generationModel: langfuseModel, routeCtxProvider: routeCtx.provider, billing, billingPolicy, @@ -196,37 +174,6 @@ interface ChatModelAliasPlan { modelIds: string[] } -function captureGeneration(input: GenerationCaptureInput): void { - const generationId = input.roundId ?? input.requestId - const conversationId = input.sessionId ?? input.requestId - const totalTokens = input.usage.promptTokens != null && input.usage.completionTokens != null - ? input.usage.promptTokens + input.usage.completionTokens - : undefined - - input.deps.productEventService.trackGeneration({ - userId: input.userId, - traceId: conversationId, - generationId, - model: input.generationModel, - provider: input.routeCtxProvider || 'unknown', - providerType: 'official', - usageSource: input.usage.promptTokens != null || input.usage.completionTokens != null - ? 'reported' - : 'unavailable', - inputTokens: input.usage.promptTokens, - outputTokens: input.usage.completionTokens, - totalTokens, - costUsdSource: 'unavailable', - conversationId, - conversationIdSource: input.sessionId ? 'client_header' : 'server_request', - roundId: generationId, - ...(input.appSurface && { appSurface: input.appSurface }), - captureSurface: 'server', - latencySeconds: input.durationMs / 1000, - stream: input.stream, - }) -} - async function resolveChatModelAliasPlan(deps: V1RouteDeps, aliasId: string): Promise { const alias = await deps.providerCatalogService.resolveEnabledAlias('llm', aliasId) const primaryRoutes = alias.routes.filter(route => route.pool === 'primary') @@ -323,11 +270,7 @@ function streamChatCompletion(input: { durationMs: number requestId: string userId: string - sessionId?: string - roundId?: string - appSurface?: AiGenerationAppSurface requestModel: string - generationModel: string routeCtxProvider: string billing: ChatBilling billingPolicy: ChatBillingPolicy @@ -431,20 +374,6 @@ function streamChatCompletion(input: { }) input.telemetry.recordMetrics({ model: input.requestModel, status: input.response.status, type: 'chat', provider: input.routeCtxProvider, durationMs: input.durationMs, fluxConsumed, ...usage }) - captureGeneration({ - deps: input.deps, - userId: input.userId, - requestId: input.requestId, - sessionId: input.sessionId, - roundId: input.roundId, - appSurface: input.appSurface, - generationModel: input.generationModel, - routeCtxProvider: input.routeCtxProvider, - usage, - durationMs: input.durationMs, - stream: true, - }) - // Debit flux via DB transaction (source of truth) // NOTICE: streaming response is already sent, so we cannot reject on failure. // Log at error level so unpaid usage is visible in monitoring/alerts. @@ -514,11 +443,7 @@ async function completeNonStreamingChat(input: { durationMs: number requestId: string userId: string - sessionId?: string - roundId?: string - appSurface?: AiGenerationAppSurface requestModel: string - generationModel: string routeCtxProvider: string billing: ChatBilling billingPolicy: ChatBillingPolicy @@ -552,20 +477,6 @@ async function completeNonStreamingChat(input: { }) input.telemetry.recordMetrics({ model: input.requestModel, status: input.response.status, type: 'chat', provider: input.routeCtxProvider, durationMs: input.durationMs, fluxConsumed, ...usage }) - captureGeneration({ - deps: input.deps, - userId: input.userId, - requestId: input.requestId, - sessionId: input.sessionId, - roundId: input.roundId, - appSurface: input.appSurface, - generationModel: input.generationModel, - routeCtxProvider: input.routeCtxProvider, - usage, - durationMs: input.durationMs, - stream: false, - }) - // Debit flux via DB transaction (source of truth). // The upstream call has already happened (cost incurred), so partial // debit + `fluxUnbilled` is the only sane recovery — same shape as the diff --git a/server/apps/api/src/routes/openai/v1/route.test.ts b/server/apps/api/src/routes/openai/v1/route.test.ts index 844b64e5f..83a003397 100644 --- a/server/apps/api/src/routes/openai/v1/route.test.ts +++ b/server/apps/api/src/routes/openai/v1/route.test.ts @@ -148,7 +148,6 @@ function createMockLlmRouter(impl?: Partial): LlmRouterService function createMockProductEventService(): ProductEventService { return { track: vi.fn(async () => undefined), - trackGeneration: vi.fn(async () => undefined), } } @@ -912,7 +911,7 @@ describe('v1CompletionsRoutes', () => { } }) - it('records Langfuse and PostHog generations with authoritative usage and correlation', async () => { + it('records Langfuse usage without forwarding generations to product analytics', async () => { const llmRouter = createMockLlmRouter({ route: vi.fn(async (_req, ctx) => { if (ctx) { @@ -962,88 +961,8 @@ describe('v1CompletionsRoutes', () => { userId: 'user-1', }), ) - expect(productEventService.trackGeneration).toHaveBeenCalledWith({ - userId: 'user-1', - traceId: 'conversation-1', - generationId: 'round-1', - model: 'openai/gpt-4o-mini', - provider: 'openrouter', - providerType: 'official', - usageSource: 'reported', - inputTokens: 1, - outputTokens: 2, - totalTokens: 3, - costUsdSource: 'unavailable', - conversationId: 'conversation-1', - conversationIdSource: 'client_header', - roundId: 'round-1', - appSurface: 'electron', - captureSurface: 'server', - latencySeconds: expect.any(Number), - stream: false, - }) - }) - - it('uses request-level correlation for server-captured generations without chat headers', async () => { - const llmRouter = createMockLlmRouter({ - route: vi.fn(async (_req, ctx) => { - if (ctx) { - ctx.provider = 'openrouter' - ctx.upstreamModel = 'openai/gpt-4o-mini' - } - return new Response(JSON.stringify({ - choices: [], - usage: { prompt_tokens: 1, completion_tokens: 2, total_tokens: 3 }, - }), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }) - }) as any, - }) - const productEventService = createMockProductEventService() - const app = createTestApp( - createMockFluxService(), - createMockConfigKV(), - undefined, - undefined, - undefined, - llmRouter, - createMockLlmTracing(), - productEventService, - ) - - await app.fetch( - new Request('http://localhost/api/v1/openai/chat/completions', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ model: 'chat-auto', messages: [{ role: 'user', content: 'hi' }] }), - }), - { user: testUser } as any, - ) - - expect(productEventService.trackGeneration).toHaveBeenCalledWith({ - userId: 'user-1', - traceId: expect.any(String), - generationId: expect.any(String), - model: 'openai/gpt-4o-mini', - provider: 'openrouter', - providerType: 'official', - usageSource: 'reported', - inputTokens: 1, - outputTokens: 2, - totalTokens: 3, - costUsdSource: 'unavailable', - conversationId: expect.any(String), - conversationIdSource: 'server_request', - roundId: expect.any(String), - captureSurface: 'server', - latencySeconds: expect.any(Number), - stream: false, - }) - const generation = vi.mocked(productEventService.trackGeneration).mock.calls[0]?.[0] - expect(generation?.traceId).toBe(generation?.conversationId) - expect(generation?.roundId).toBe(generation?.generationId) - expect(generation).not.toHaveProperty('appSurface') + expect(llmTracing.startChatGeneration).toHaveBeenCalledWith(expect.objectContaining({ sessionId: 'conversation-1' })) + expect(productEventService.track).not.toHaveBeenCalled() }) it('should not charge flux when upstream returns error', async () => { diff --git a/server/apps/api/src/routes/stripe/operations/checkout.ts b/server/apps/api/src/routes/stripe/operations/checkout.ts index b3334d098..4a90c2566 100644 --- a/server/apps/api/src/routes/stripe/operations/checkout.ts +++ b/server/apps/api/src/routes/stripe/operations/checkout.ts @@ -33,7 +33,7 @@ export interface CheckoutOperationInput { request: Request } -interface PosthogIdentityHeaders { +interface OpenpanelIdentityHeaders { distinctId?: string sessionId?: string } @@ -80,7 +80,7 @@ export function createCheckoutOperation(deps: CheckoutOperationDeps) { const paymentMethods = await deps.configKV.getOptional('STRIPE_PAYMENT_METHODS') const paymentMethodOptions = await deps.configKV.getOptional('STRIPE_PAYMENT_METHOD_OPTIONS') ?? {} - const posthogIdentity = readPosthogIdentityHeaders(input.request) + const openpanelIdentity = readOpenpanelIdentityHeaders(input.request) const sessionParams: CheckoutSessionCreateParams = { line_items: [{ price: stripePriceId, quantity: 1 }], @@ -93,8 +93,8 @@ export function createCheckoutOperation(deps: CheckoutOperationDeps) { metadata: { userId: input.user.id, fluxAmount: String(fluxAmount), - ...(posthogIdentity.distinctId && { posthogDistinctId: posthogIdentity.distinctId }), - ...(posthogIdentity.sessionId && { posthogSessionId: posthogIdentity.sessionId }), + ...(openpanelIdentity.distinctId && { openpanelDeviceId: openpanelIdentity.distinctId }), + ...(openpanelIdentity.sessionId && { openpanelSessionId: openpanelIdentity.sessionId }), }, } @@ -142,8 +142,8 @@ export function createCheckoutOperation(deps: CheckoutOperationDeps) { flux_amount: fluxAmount, amount_total: session.amount_total, currency: session.currency, - ...(posthogIdentity.distinctId && { posthog_distinct_id: posthogIdentity.distinctId }), - ...(posthogIdentity.sessionId && { posthog_session_id: posthogIdentity.sessionId }), + ...(openpanelIdentity.distinctId && { openpanel_device_id: openpanelIdentity.distinctId }), + ...(openpanelIdentity.sessionId && { openpanel_session_id: openpanelIdentity.sessionId }), }, }) @@ -151,9 +151,9 @@ export function createCheckoutOperation(deps: CheckoutOperationDeps) { } } -function readPosthogIdentityHeaders(request: Request): PosthogIdentityHeaders { - const distinctId = readStripeMetadataHeader(request, 'x-posthog-distinct-id') - const sessionId = readStripeMetadataHeader(request, 'x-posthog-session-id') +function readOpenpanelIdentityHeaders(request: Request): OpenpanelIdentityHeaders { + const distinctId = readStripeMetadataHeader(request, 'x-openpanel-device-id') + const sessionId = readStripeMetadataHeader(request, 'x-openpanel-session-id') return { ...(distinctId && { distinctId }), ...(sessionId && { sessionId }), diff --git a/server/apps/api/src/routes/stripe/operations/webhook.ts b/server/apps/api/src/routes/stripe/operations/webhook.ts index e79d683fd..f6ef0edf2 100644 --- a/server/apps/api/src/routes/stripe/operations/webhook.ts +++ b/server/apps/api/src/routes/stripe/operations/webhook.ts @@ -89,8 +89,8 @@ export function createWebhookOperation(deps: WebhookOperationDeps) { const userId = event.data.object.metadata?.userId if (userId) { const fluxAmount = Number(event.data.object.metadata?.fluxAmount) - const posthogDistinctId = event.data.object.metadata?.posthogDistinctId - const posthogSessionId = event.data.object.metadata?.posthogSessionId + const openpanelDeviceId = event.data.object.metadata?.openpanelDeviceId + const openpanelSessionId = event.data.object.metadata?.openpanelSessionId void deps.productEventService?.track({ userId, feature: 'billing', @@ -104,8 +104,8 @@ export function createWebhookOperation(deps: WebhookOperationDeps) { flux_amount: Number.isFinite(fluxAmount) ? fluxAmount : null, stripe_checkout_session_id: event.data.object.id, stripe_customer_id: typeof event.data.object.customer === 'string' ? event.data.object.customer : event.data.object.customer?.id ?? null, - ...(posthogDistinctId && { posthog_distinct_id: posthogDistinctId }), - ...(posthogSessionId && { posthog_session_id: posthogSessionId }), + ...(openpanelDeviceId && { openpanel_device_id: openpanelDeviceId }), + ...(openpanelSessionId && { openpanel_session_id: openpanelSessionId }), }, }) } @@ -228,6 +228,9 @@ async function handleCheckoutSessionCompleted( applied: result.applied, balanceAfter: result.balanceAfter, }).log('Processed flux credit for one-time payment') + + // Only the transaction that credits this checkout emits its conversion. + return { processed: result.applied } } return { processed: true } diff --git a/server/apps/api/src/routes/stripe/route.test.ts b/server/apps/api/src/routes/stripe/route.test.ts index 17be908a2..f250f0033 100644 --- a/server/apps/api/src/routes/stripe/route.test.ts +++ b/server/apps/api/src/routes/stripe/route.test.ts @@ -296,7 +296,7 @@ describe('stripeRoutes', () => { expect(res.status).toBe(503) }) - it('stores browser PostHog identity in Stripe checkout metadata', async () => { + it('stores browser OpenPanel identity in Stripe checkout metadata', async () => { const createSession = vi.fn(async input => ({ id: 'cs_1', url: 'https://checkout.stripe.com/cs_1', @@ -343,8 +343,8 @@ describe('stripeRoutes', () => { body: { stripePriceId: 'price_test_500' }, request: new Request('http://localhost/api/v1/stripe/checkout', { headers: { - 'x-posthog-distinct-id': 'anon-browser-1', - 'x-posthog-session-id': 'ph-session-1', + 'x-openpanel-device-id': 'anon-browser-1', + 'x-openpanel-session-id': 'ph-session-1', }, }), }) @@ -353,16 +353,16 @@ describe('stripeRoutes', () => { metadata: { userId: 'user-1', fluxAmount: '500', - posthogDistinctId: 'anon-browser-1', - posthogSessionId: 'ph-session-1', + openpanelDeviceId: 'anon-browser-1', + openpanelSessionId: 'ph-session-1', }, })) expect(productEventService.track).toHaveBeenCalledWith(expect.objectContaining({ userId: 'user-1', action: 'checkout_started', metadata: expect.objectContaining({ - posthog_distinct_id: 'anon-browser-1', - posthog_session_id: 'ph-session-1', + openpanel_device_id: 'anon-browser-1', + openpanel_session_id: 'ph-session-1', }), })) }) @@ -535,7 +535,7 @@ describe('stripeRoutes', () => { expect(res.status).toBe(503) }) - it('records payment completion with Stripe and PostHog identity from checkout metadata', async () => { + it('records payment completion with Stripe and OpenPanel identity from checkout metadata', async () => { const checkoutEvent = { id: 'evt_checkout_completed', type: 'checkout.session.completed', @@ -556,8 +556,8 @@ describe('stripeRoutes', () => { metadata: { userId: 'user-1', fluxAmount: '500', - posthogDistinctId: 'anon-browser-1', - posthogSessionId: 'ph-session-1', + openpanelDeviceId: 'anon-browser-1', + openpanelSessionId: 'ph-session-1', }, expires_at: null, }, @@ -599,10 +599,14 @@ describe('stripeRoutes', () => { flux_amount: 500, stripe_checkout_session_id: 'cs_1', stripe_customer_id: 'cus_1', - posthog_distinct_id: 'anon-browser-1', - posthog_session_id: 'ph-session-1', + openpanel_device_id: 'anon-browser-1', + openpanel_session_id: 'ph-session-1', }, }) + + vi.mocked(billingService.creditFluxFromStripeCheckout).mockResolvedValueOnce({ applied: false }) + await webhook({ signature: 'test_sig', body: '{}' }) + expect(productEventService.track).toHaveBeenCalledTimes(1) }) it('processes subscription lifecycle webhooks without product events', async () => { diff --git a/server/apps/api/src/services/adapters/openpanel.test.ts b/server/apps/api/src/services/adapters/openpanel.test.ts new file mode 100644 index 000000000..973b830b5 --- /dev/null +++ b/server/apps/api/src/services/adapters/openpanel.test.ts @@ -0,0 +1,58 @@ +import { createServer } from 'node:http' + +import { describe, expect, it } from 'vitest' + +import { createOpenpanelSink } from './openpanel' + +describe('openPanel sink', () => { + it('isolates concurrent user identities and preserves checkout device attribution', async () => { + const bodies: string[] = [] + const server = createServer(async (request, response) => { + let body = '' + for await (const chunk of request) + body += chunk + bodies.push(body) + response.writeHead(200).end('{}') + }) + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) + const address = server.address() + if (!address || typeof address === 'string') + throw new Error('Expected a TCP server address') + try { + const sink = createOpenpanelSink({ apiUrl: `http://127.0.0.1:${address.port}`, clientId: 'test-client', clientSecret: 'test-secret' }) + await Promise.all([ + sink.capture({ userId: 'alice', event: 'payment_completed', deviceId: 'alice-device', properties: { event_id: 'cs_alice' } }), + sink.capture({ userId: 'bob', event: 'signup_completed', properties: {} }), + ]) + expect(bodies.map(body => JSON.parse(body))).toEqual(expect.arrayContaining([ + { type: 'track', payload: { name: 'payment_completed', profileId: 'alice', properties: { event_id: 'cs_alice', __deviceId: 'alice-device' } } }, + { type: 'track', payload: { name: 'signup_completed', profileId: 'bob', properties: {} } }, + ])) + } + finally { + server.closeAllConnections() + await new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve())) + } + }) + + it('does not retry a rejected conversion request', async () => { + let requests = 0 + const server = createServer((_request, response) => { + requests++ + response.writeHead(503).end('Unavailable') + }) + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) + const address = server.address() + if (!address || typeof address === 'string') + throw new Error('Expected a TCP server address') + try { + const sink = createOpenpanelSink({ apiUrl: `http://127.0.0.1:${address.port}`, clientId: 'test-client', clientSecret: 'test-secret' }) + await expect(sink.capture({ userId: 'alice', event: 'payment_completed', properties: {} })).rejects.toThrow('503') + expect(requests).toBe(1) + } + finally { + server.closeAllConnections() + await new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve())) + } + }) +}) diff --git a/server/apps/api/src/services/adapters/openpanel.ts b/server/apps/api/src/services/adapters/openpanel.ts new file mode 100644 index 000000000..5bf3c39e4 --- /dev/null +++ b/server/apps/api/src/services/adapters/openpanel.ts @@ -0,0 +1,53 @@ +import type { TrackHandlerPayload } from '@openpanel/sdk' + +/** One confirmed product fact, with an optional browser device for attribution. */ +export interface ProductCaptureInput { + userId: string + event: string + properties: Record + deviceId?: string +} + +/** External delivery boundary for confirmed product facts. */ +export interface ProductAnalyticsSink { + capture: (input: ProductCaptureInput) => Promise +} + +/** + * Sends each fact once. Callers own replay suppression through business state. + * Each request supplies its user id, so concurrent users never share SDK identity. + */ +export function createOpenpanelSink(options: { clientId: string, clientSecret: string, apiUrl: string }): ProductAnalyticsSink { + const endpoint = `${options.apiUrl.replace(/\/$/, '')}/track` + + return { + async capture(input) { + const payload: TrackHandlerPayload = { + type: 'track', + payload: { + name: input.event, + profileId: input.userId, + properties: { + ...input.properties, + ...(input.deviceId && { __deviceId: input.deviceId }), + }, + }, + } + // OpenPanel does not deduplicate events by a caller-supplied UUID. Retrying an + // ambiguous response can count a paid conversion twice. + const response = await fetch(endpoint, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'openpanel-client-id': options.clientId, + 'openpanel-client-secret': options.clientSecret, + 'openpanel-sdk-name': 'node', + }, + body: JSON.stringify(payload), + signal: AbortSignal.timeout(5000), + }) + if (!response.ok) + throw new Error(`OpenPanel rejected the product event (${response.status})`) + }, + } +} diff --git a/server/apps/api/src/services/adapters/posthog.ts b/server/apps/api/src/services/adapters/posthog.ts deleted file mode 100644 index 931e9b911..000000000 --- a/server/apps/api/src/services/adapters/posthog.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { useLogger } from '@guiiai/logg' -import { PostHog } from 'posthog-node' - -const logger = useLogger('posthog') - -/** - * One product event forwarded to PostHog, keyed by the Better Auth user id - * so it merges with the browser person identified via `posthog.identify()`. - */ -export interface PosthogCaptureInput { - distinctId: string - event: string - properties: Record - /** Stable event UUID used by PostHog ingestion for replay deduplication. */ - uuid?: string -} - -/** - * Minimal capture boundary the product-events service depends on. Kept as - * an interface so tests inject a fake instead of mocking the SDK. - */ -export interface PosthogSink { - /** - * Queue a high-volume analytics event without waiting for a network - * roundtrip. Use on request hot paths where occasional process-exit loss is - * preferable to user-visible latency. - */ - captureQueued?: (input: PosthogCaptureInput) => void - capture: (input: PosthogCaptureInput) => Promise - /** Flush and close the underlying client. Call on server shutdown. */ - shutdown: () => Promise -} - -/** - * PostHog sink for server-side product events. - * - * Low-frequency conversion facts use `captureImmediate` (one HTTP roundtrip - * per event) because they terminate money/auth funnels. High-frequency AI - * generation facts use `captureQueued`, which is buffered by the SDK and - * flushed on shutdown, so chat completion requests don't wait on PostHog. - * - * Capture failures are logged and swallowed. Analytics forwarding never - * fails the Stripe webhook or auth flow that produced the business fact. - */ -export function createPosthogSink(options: { projectKey: string, host: string }): PosthogSink { - const client = new PostHog(options.projectKey, { host: options.host }) - - return { - captureQueued(input: PosthogCaptureInput): void { - try { - client.capture({ - distinctId: input.distinctId, - event: input.event, - properties: input.properties, - ...(input.uuid && { uuid: input.uuid }), - }) - } - catch (err) { - logger.withError(err).withFields({ event: input.event }).warn('Failed to enqueue product event to PostHog') - } - }, - - async capture(input: PosthogCaptureInput): Promise { - try { - await client.captureImmediate({ - distinctId: input.distinctId, - event: input.event, - properties: input.properties, - ...(input.uuid && { uuid: input.uuid }), - }) - } - catch (err) { - logger.withError(err).withFields({ event: input.event }).warn('Failed to forward product event to PostHog') - } - }, - - async shutdown(): Promise { - await client.shutdown() - }, - } -} diff --git a/server/apps/api/src/services/domain/product-events.test.ts b/server/apps/api/src/services/domain/product-events.test.ts index 581d43c03..e640c4355 100644 --- a/server/apps/api/src/services/domain/product-events.test.ts +++ b/server/apps/api/src/services/domain/product-events.test.ts @@ -1,181 +1,67 @@ +import type { ProductCaptureInput } from '../adapters/openpanel' + import { describe, expect, it, vi } from 'vitest' import { createProductEventService } from './product-events' describe('productEventService', () => { - it('captures only the server-side funnel facts shared with the Go service', async () => { - const capture = vi.fn(async () => {}) - const service = createProductEventService({ capture, shutdown: vi.fn(async () => {}) }) - - await service.track({ - userId: 'user-1', - feature: 'auth', - action: 'user_signed_up', - status: 'succeeded', - }) - await service.track({ - userId: 'user-1', - feature: 'billing', - action: 'checkout_started', - status: 'succeeded', - source: 'stripe.checkout', - }) - await service.track({ - userId: 'user-1', - feature: 'billing', - action: 'payment_completed', - status: 'succeeded', - source: 'stripe.webhook', - metadata: { amount_minor_unit: 990, currency: 'usd' }, - }) + it('routes confirmed product facts to OpenPanel with stable AIRI user ids', async () => { + const capture = vi.fn<(input: ProductCaptureInput) => Promise>().mockResolvedValue(undefined) + const service = createProductEventService({ capture }) + await service.track({ userId: 'user-1', feature: 'auth', action: 'user_signed_up', status: 'succeeded' }) + await service.track({ userId: 'user-2', feature: 'billing', action: 'checkout_started', status: 'succeeded' }) expect(capture).toHaveBeenNthCalledWith(1, { - distinctId: 'user-1', + userId: 'user-1', event: 'signup_completed', - properties: { - app_surface: 'server', - airi_user_id: 'user-1', - feature: 'auth', - status: 'succeeded', - }, - }) - expect(capture).toHaveBeenNthCalledWith(2, { - distinctId: 'user-1', - event: 'checkout_created', - properties: { - app_surface: 'server', - airi_user_id: 'user-1', - feature: 'billing', - status: 'succeeded', - source: 'stripe.checkout', - }, - }) - expect(capture).toHaveBeenNthCalledWith(3, { - distinctId: 'user-1', - event: 'payment_completed', - properties: { - app_surface: 'server', - airi_user_id: 'user-1', - feature: 'billing', - status: 'succeeded', - source: 'stripe.webhook', - amount_minor_unit: 990, - currency: 'usd', - }, + properties: { app_surface: 'server', airi_user_id: 'user-1', feature: 'auth', status: 'succeeded' }, }) + expect(capture).toHaveBeenNthCalledWith(2, expect.objectContaining({ userId: 'user-2', event: 'checkout_created' })) }) - it('merges a Stripe conversion with its browser PostHog person', async () => { - const capture = vi.fn(async () => {}) - const service = createProductEventService({ capture, shutdown: vi.fn(async () => {}) }) - + it('passes the checkout device id and source event id without an extra identify event', async () => { + const capture = vi.fn<(input: ProductCaptureInput) => Promise>().mockResolvedValue(undefined) + const service = createProductEventService({ capture }) await service.track({ userId: 'user-1', feature: 'billing', action: 'payment_completed', status: 'succeeded', eventId: 'cs_123', - metadata: { - posthog_distinct_id: 'anon-browser-1', - posthog_session_id: 'ph-session-1', - }, + metadata: { openpanel_device_id: 'browser-1', amount_total: 990, currency: 'usd' }, }) - - expect(capture).toHaveBeenNthCalledWith(1, { - distinctId: 'user-1', - event: '$identify', - uuid: expect.stringMatching(/^[0-9a-f]{8}-[0-9a-f]{4}-5[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/), - properties: { - $insert_id: 'cs_123', - $anon_distinct_id: 'anon-browser-1', - $session_id: 'ph-session-1', - airi_user_id: 'user-1', - }, - }) - expect(capture).toHaveBeenNthCalledWith(2, expect.objectContaining({ - distinctId: 'user-1', - event: 'payment_completed', - uuid: expect.stringMatching(/^[0-9a-f]{8}-[0-9a-f]{4}-5[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/), - properties: expect.objectContaining({ $insert_id: 'cs_123' }), - })) - }) - - it('uses a stable PostHog UUID for replayed conversion captures', async () => { - const capture = vi.fn(async () => {}) - const service = createProductEventService({ capture, shutdown: vi.fn(async () => {}) }) - const input = { - userId: 'user-1' as const, - feature: 'billing' as const, - action: 'payment_completed' as const, - status: 'succeeded' as const, - eventId: 'cs_replayed', - } - - await service.track(input) - await service.track(input) - - expect(capture).toHaveBeenCalledTimes(2) - const captures = capture.mock.calls as unknown as Array<[ - { - uuid?: string - properties: Record - }, - ]> - const first = captures[0]![0] - const replay = captures[1]![0] - expect(first.uuid).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-5[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/) - expect(replay.uuid).toBe(first.uuid) - expect(first.properties.$insert_id).toBe('cs_replayed') - }) - - it('rejects metadata that can overwrite service-controlled PostHog properties', async () => { - const capture = vi.fn(async () => {}) - const service = createProductEventService({ capture, shutdown: vi.fn(async () => {}) }) - - await expect(service.track({ + expect(capture).toHaveBeenCalledTimes(1) + expect(capture).toHaveBeenCalledWith({ userId: 'user-1', - feature: 'billing', - action: 'payment_completed', - status: 'succeeded', - metadata: { $insert_id: 'spoofed' }, - })).resolves.toBeUndefined() + deviceId: 'browser-1', + event: 'payment_completed', + properties: { + openpanel_device_id: 'browser-1', + amount_total: 990, + currency: 'usd', + event_id: 'cs_123', + app_surface: 'server', + airi_user_id: 'user-1', + feature: 'billing', + status: 'succeeded', + }, + }) + }) + it('rejects metadata that can forge provider identity or the source event id', async () => { + const capture = vi.fn(async () => {}) + const service = createProductEventService({ capture }) + for (const key of ['event_id', '__deviceId', 'profileId']) { + await service.track({ userId: 'user-1', feature: 'billing', action: 'payment_completed', status: 'succeeded', metadata: { [key]: 'forged' } }) + } expect(capture).not.toHaveBeenCalled() }) - it('still captures the funnel event when identity merging fails', async () => { - const capture = vi.fn() - .mockRejectedValueOnce(new Error('identify failed')) - .mockResolvedValueOnce(undefined) - const service = createProductEventService({ capture, shutdown: vi.fn(async () => {}) }) - - await expect(service.track({ - userId: 'user-1', - feature: 'billing', - action: 'payment_completed', - status: 'succeeded', - eventId: 'cs_456', - metadata: { posthog_distinct_id: 'anon-browser-1' }, - })).resolves.toBeUndefined() - - expect(capture).toHaveBeenCalledTimes(2) - expect(capture).toHaveBeenNthCalledWith(2, expect.objectContaining({ - event: 'payment_completed', - properties: expect.objectContaining({ $insert_id: 'cs_456' }), - })) - }) - - it('does not fail a business path when capture throws', async () => { + it('does not fail the business path when OpenPanel is unavailable', async () => { const capture = vi.fn(async () => { - throw new Error('posthog exploded') + throw new Error('unavailable') }) - const service = createProductEventService({ capture, shutdown: vi.fn(async () => {}) }) - - await expect(service.track({ - userId: 'user-1', - feature: 'billing', - action: 'payment_completed', - status: 'succeeded', - })).resolves.toBeUndefined() + const service = createProductEventService({ capture }) + await expect(service.track({ userId: 'user-1', feature: 'billing', action: 'payment_completed', status: 'succeeded' })).resolves.toBeUndefined() }) }) diff --git a/server/apps/api/src/services/domain/product-events.ts b/server/apps/api/src/services/domain/product-events.ts index 1238bf701..44ffc7cc0 100644 --- a/server/apps/api/src/services/domain/product-events.ts +++ b/server/apps/api/src/services/domain/product-events.ts @@ -1,13 +1,14 @@ -import type { PosthogSink } from '../adapters/posthog' - -import { createHash } from 'node:crypto' +import type { ProductAnalyticsSink } from '../adapters/openpanel' import { useLogger } from '@guiiai/logg' const logger = useLogger('product-events') -const RESERVED_POSTHOG_METADATA_KEYS = new Set([ - '$insert_id', +const RESERVED_PRODUCT_METADATA_KEYS = new Set([ + 'event_id', + '__deviceId', + '__identify', + 'profileId', '$session_id', 'airi_user_id', 'app_surface', @@ -27,9 +28,9 @@ export type ProductAction | 'checkout_started' | 'payment_completed' -/** Product funnel fact forwarded to PostHog from the server. */ +/** Product funnel fact forwarded to OpenPanel from the server. */ export interface ProductEventInput { - /** Better Auth user id. Kept in Postgres only; never emitted as a Prometheus label. */ + /** Authenticated user id used for product attribution. Never emitted as a Prometheus label. */ userId: string /** Bounded product area used for product dashboards and funnels. */ feature: ProductFeature @@ -41,58 +42,19 @@ export interface ProductEventInput { source?: string /** Optional primitive metadata for product analysis. Avoid PII and raw prompts. */ metadata?: ProductEventMetadata - /** Stable source event id used by PostHog for replay-safe deduplication. */ + /** Stable source event id for reconciliation. Callers suppress replays before capture. */ eventId?: string } -/** Product runtime where the user initiated the AI generation. */ -export type AiGenerationAppSurface = 'web' | 'mobile' | 'electron' - -/** Runtime that captured the `$ai_generation` fact. */ -export type AiGenerationCaptureSurface = 'server' | 'client' - -/** Explains whether `conversation_id` is an app conversation or a server fallback. */ -export type AiGenerationConversationIdSource = 'client_header' | 'server_request' - -/** Explains whether AIRI supplied a trustworthy USD cost for this generation. */ -export type AiGenerationCostUsdSource = 'reported' | 'estimated' | 'unavailable' - -/** Content-free PostHog AI generation fact keyed to the authenticated user. */ -export interface AiGenerationEventInput { - userId: string - traceId: string - generationId: string - model: string - provider: string - providerType: 'official' | 'custom' | 'unknown' - usageSource: 'reported' | 'estimated' | 'unavailable' - inputTokens?: number - outputTokens?: number - totalTokens?: number - totalCostUsd?: number - costUsdSource?: AiGenerationCostUsdSource - /** Always present for joins; `conversationIdSource` tells whether it is request-level fallback. */ - conversationId: string - /** Distinguishes real client conversation ids from server-generated request fallbacks. */ - conversationIdSource: AiGenerationConversationIdSource - roundId?: string - /** Omitted when the server cannot determine the user's product runtime. */ - appSurface?: AiGenerationAppSurface - /** Defaults to `server` because this service runs in the API process. */ - captureSurface?: AiGenerationCaptureSurface - latencySeconds?: number - stream?: boolean -} - /** - * Server-side actions that anchor a PostHog product funnel. Per-request LLM + * Server-side actions that anchor the product funnel. Per-request LLM * and TTS telemetry stays in operational systems and does not enter this path. * * `user_signed_up` maps to `signup_completed` because the identified server * hook is the canonical registration fact for every signup method. Anonymous * auth UI progress uses `signup_form_completed` and never reuses this name. */ -const POSTHOG_FORWARDED_ACTIONS: Partial> = { +const FORWARDED_ACTIONS: Partial> = { user_signed_up: 'signup_completed', checkout_started: 'checkout_created', payment_completed: 'payment_completed', @@ -103,20 +65,12 @@ function stringMetadata(input: ProductEventInput, key: string): string | undefin return typeof value === 'string' && value.length > 0 ? value : undefined } -function posthogEventUuid(event: string, eventId: string): string { - const digest = createHash('sha256').update(`airi:posthog:${event}:${eventId}`, 'utf8').digest() - digest[6] = (digest[6] & 0x0F) | 0x50 - digest[8] = (digest[8] & 0x3F) | 0x80 - const hex = digest.subarray(0, 16).toString('hex') - return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}` -} - function hasReservedMetadataKey(metadata: ProductEventMetadata | undefined): boolean { - return metadata != null && Object.keys(metadata).some(key => RESERVED_POSTHOG_METADATA_KEYS.has(key)) + return metadata != null && Object.keys(metadata).some(key => RESERVED_PRODUCT_METADATA_KEYS.has(key)) } /** - * Creates AIRI's server-side PostHog product analytics writer. + * Sends confirmed product facts to OpenPanel. * * Use when: * - A server has an authenticated user id and confirms a funnel fact. @@ -128,102 +82,38 @@ function hasReservedMetadataKey(metadata: ProductEventMetadata | undefined): boo * Returns: * - A best-effort event writer. Capture errors never change the business flow. */ -export function createProductEventService(posthog?: PosthogSink | null) { +export function createProductEventService(sink?: ProductAnalyticsSink | null) { return { - trackGeneration(input: AiGenerationEventInput): void { - if (!posthog) - return - - const event = { - distinctId: input.userId, - event: '$ai_generation', - properties: { - $ai_trace_id: input.traceId, - $ai_session_id: input.conversationId, - $ai_span_id: input.generationId, - $ai_model: input.model, - $ai_provider: input.provider, - ...(input.inputTokens != null && { $ai_input_tokens: input.inputTokens }), - ...(input.outputTokens != null && { $ai_output_tokens: input.outputTokens }), - ...(input.totalTokens != null && { $ai_total_tokens: input.totalTokens }), - ...(input.totalCostUsd != null && { $ai_total_cost_usd: input.totalCostUsd }), - ...(input.latencySeconds != null && { $ai_latency: input.latencySeconds }), - ...(input.stream != null && { $ai_stream: input.stream }), - $insert_id: `ai-generation:${input.generationId}`, - airi_user_id: input.userId, - provider_type: input.providerType, - usage_source: input.usageSource, - token_usage_available: input.usageSource !== 'unavailable', - cost_usd_source: input.costUsdSource ?? 'unavailable', - cost_usd_known: input.totalCostUsd != null, - conversation_id: input.conversationId, - conversation_id_source: input.conversationIdSource, - ...(input.roundId && { round_id: input.roundId }), - ...(input.appSurface && { app_surface: input.appSurface }), - capture_surface: input.captureSurface ?? 'server', - }, - } - - if (posthog.captureQueued) { - posthog.captureQueued(event) - return - } - - void posthog.capture(event) - .catch(err => logger.withError(err).withFields({ generationId: input.generationId }).warn('Failed to capture PostHog AI generation')) - }, - async track(input: ProductEventInput): Promise { - const forwardedEvent = POSTHOG_FORWARDED_ACTIONS[input.action] - if (!posthog || !forwardedEvent) + const forwardedEvent = FORWARDED_ACTIONS[input.action] + if (!sink || !forwardedEvent) return if (hasReservedMetadataKey(input.metadata)) { - logger.withFields({ action: input.action }).warn('Rejected reserved PostHog product event metadata') + logger.withFields({ action: input.action }).warn('Rejected reserved product event metadata') return } - const posthogDistinctId = stringMetadata(input, 'posthog_distinct_id') - const posthogSessionId = stringMetadata(input, 'posthog_session_id') - if (posthogDistinctId && posthogDistinctId !== input.userId) { - try { - await posthog.capture({ - distinctId: input.userId, - event: '$identify', - properties: { - ...(input.eventId && { $insert_id: input.eventId }), - $anon_distinct_id: posthogDistinctId, - airi_user_id: input.userId, - ...(posthogSessionId && { $session_id: posthogSessionId }), - }, - ...(input.eventId && { uuid: posthogEventUuid('$identify', input.eventId) }), - }) - } - catch (err) { - logger.withError(err).withFields({ action: input.action }).warn('PostHog anonymous identity capture failed') - } - } + const deviceId = stringMetadata(input, 'openpanel_device_id') try { - await posthog.capture({ - distinctId: input.userId, + await sink.capture({ + userId: input.userId, + ...(deviceId && { deviceId }), event: forwardedEvent, properties: { ...input.metadata, - ...(input.eventId && { $insert_id: input.eventId }), + ...(input.eventId && { event_id: input.eventId }), app_surface: 'server', airi_user_id: input.userId, - ...(posthogDistinctId && { posthog_distinct_id: posthogDistinctId }), - ...(posthogSessionId && { $session_id: posthogSessionId }), feature: input.feature, status: input.status, ...(input.source && { source: input.source }), }, - ...(input.eventId && { uuid: posthogEventUuid(forwardedEvent, input.eventId) }), }) } catch (err) { - logger.withError(err).withFields({ action: input.action }).warn('PostHog product analytics capture failed') + logger.withError(err).withFields({ action: input.action }).warn('OpenPanel product analytics capture failed') } }, } diff --git a/vite-env.d.ts b/vite-env.d.ts index 193a95051..8cc7a9d81 100644 --- a/vite-env.d.ts +++ b/vite-env.d.ts @@ -4,8 +4,7 @@ interface ImportMetaEnv { readonly VITE_DISTRIBUTION?: 'direct' | 'steam' readonly VITE_DISABLE_FLUX_PURCHASE?: string readonly VITE_DISABLE_CUSTOM_PROVIDERS?: string - readonly VITE_ENABLE_POSTHOG?: string - readonly VITE_POSTHOG_PROJECT_KEY?: string + readonly VITE_ENABLE_ANALYTICS?: string } interface ImportMeta {