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

This commit is contained in:
RainbowBird
2026-09-09 08:26:18 +00:00
committed by GitHub
parent 5e78b64e35
commit 66f0d45028
68 changed files with 802 additions and 1252 deletions
@@ -49,16 +49,8 @@ jobs:
- name: Build ui-server-auth - name: Build ui-server-auth
run: pnpm -F @proj-airi/ui-server-auth run build run: pnpm -F @proj-airi/ui-server-auth run build
env: env:
VITE_ENABLE_POSTHOG: 'true' VITE_ENABLE_ANALYTICS: 'true'
VITE_SERVER_URL: 'https://api.airi.build' 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 - uses: cloudflare/wrangler-action@v4
with: with:
@@ -69,17 +69,9 @@ jobs:
S3_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_ACCESS_KEY }} S3_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_ACCESS_KEY }}
S3_ENDPOINT: ${{ secrets.S3_ENDPOINT }} S3_ENDPOINT: ${{ secrets.S3_ENDPOINT }}
S3_REGION: ${{ secrets.S3_REGION }} S3_REGION: ${{ secrets.S3_REGION }}
VITE_ENABLE_POSTHOG: 'true' VITE_ENABLE_ANALYTICS: 'true'
VITE_SERVER_URL: 'https://api.airi.build' VITE_SERVER_URL: 'https://api.airi.build'
WARP_DRIVE_PUBLIC_BASE: ${{ secrets.WARP_DRIVE_PUBLIC_BASE }} 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 - uses: cloudflare/wrangler-action@v4
with: with:
@@ -33,7 +33,7 @@ jobs:
env: env:
TARGET_HUGGINGFACE_SPACE: 'true' TARGET_HUGGINGFACE_SPACE: 'true'
VITE_APP_TARGET_HUGGINGFACE_SPACE: 'true' VITE_APP_TARGET_HUGGINGFACE_SPACE: 'true'
VITE_ENABLE_POSTHOG: 'true' VITE_ENABLE_ANALYTICS: 'true'
- id: airi_diff - id: airi_diff
working-directory: ./apps/stage-web/dist working-directory: ./apps/stage-web/dist
+1 -1
View File
@@ -46,7 +46,7 @@ jobs:
context: ./ context: ./
file: ./apps/stage-web/Dockerfile file: ./apps/stage-web/Dockerfile
build-args: | build-args: |
VITE_ENABLE_POSTHOG=true VITE_ENABLE_ANALYTICS=true
platforms: linux/amd64,linux/arm64,linux/arm64/v8 platforms: linux/amd64,linux/arm64,linux/arm64/v8
cache-from: type=gha cache-from: type=gha
cache-to: type=gha,mode=max cache-to: type=gha,mode=max
+1 -1
View File
@@ -29,7 +29,7 @@ jobs:
env: env:
PRODUCT_NAME: 'AIRI' PRODUCT_NAME: 'AIRI'
# Every current trigger path produces an installable APK artifact, so ship the analytics-enabled build consistently. # 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: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v6
+1 -1
View File
@@ -26,7 +26,7 @@ jobs:
runs-on: macos-26 runs-on: macos-26
env: env:
PRODUCT_NAME: 'AIRI' PRODUCT_NAME: 'AIRI'
VITE_ENABLE_POSTHOG: 'true' VITE_ENABLE_ANALYTICS: 'true'
UPLOAD_TESTFLIGHT: ${{ github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && inputs.testflight) }} UPLOAD_TESTFLIGHT: ${{ github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && inputs.testflight) }}
RELEASE_TAG: ${{ github.event.release.tag_name || inputs.tag }} RELEASE_TAG: ${{ github.event.release.tag_name || inputs.tag }}
steps: steps:
@@ -37,7 +37,7 @@ jobs:
VITE_DISTRIBUTION: steam VITE_DISTRIBUTION: steam
VITE_DISABLE_CUSTOM_PROVIDERS: 'true' VITE_DISABLE_CUSTOM_PROVIDERS: 'true'
VITE_DISABLE_FLUX_PURCHASE: 'true' VITE_DISABLE_FLUX_PURCHASE: 'true'
VITE_ENABLE_POSTHOG: 'false' VITE_ENABLE_ANALYTICS: 'false'
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
+1 -1
View File
@@ -48,7 +48,7 @@ jobs:
name: Build name: Build
continue-on-error: ${{ matrix.skip }} # WORKAROUND: Skipping subsequent steps without failing the whole workflow continue-on-error: ${{ matrix.skip }} # WORKAROUND: Skipping subsequent steps without failing the whole workflow
env: 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: strategy:
matrix: matrix:
include: include:
-1
View File
@@ -85,7 +85,6 @@
"nprogress": "catalog:", "nprogress": "catalog:",
"onnxruntime-web": "catalog:", "onnxruntime-web": "catalog:",
"pinia": "catalog:", "pinia": "catalog:",
"posthog-js": "catalog:",
"rehype-stringify": "catalog:", "rehype-stringify": "catalog:",
"reka-ui": "catalog:", "reka-ui": "catalog:",
"remark-parse": "catalog:", "remark-parse": "catalog:",
+2 -2
View File
@@ -33,8 +33,8 @@ import './styles/main.css'
import 'uno.css' import 'uno.css'
configureAnalyticsAdapter(async (options) => { configureAnalyticsAdapter(async (options) => {
const { createPosthogAdapter } = await import('@proj-airi/stage-ui/libs/product-signals/posthog') const { createOpenpanelAdapter } = await import('@proj-airi/stage-ui/libs/product-signals/openpanel')
return createPosthogAdapter(options) return createOpenpanelAdapter(options)
}) })
if (Capacitor.isNativePlatform()) { if (Capacitor.isNativePlatform()) {
-1
View File
@@ -120,7 +120,6 @@
"onnxruntime-web": "catalog:", "onnxruntime-web": "catalog:",
"pinia": "catalog:", "pinia": "catalog:",
"popmotion": "catalog:", "popmotion": "catalog:",
"posthog-js": "catalog:",
"rehype-stringify": "catalog:", "rehype-stringify": "catalog:",
"reka-ui": "catalog:", "reka-ui": "catalog:",
"remark-parse": "catalog:", "remark-parse": "catalog:",
+2 -2
View File
@@ -42,8 +42,8 @@ import '@fontsource/m-plus-rounded-1c/index.css'
import '@fontsource-variable/nunito/index.css' import '@fontsource-variable/nunito/index.css'
configureAnalyticsAdapter(async (options) => { configureAnalyticsAdapter(async (options) => {
const { createPosthogAdapter } = await import('@proj-airi/stage-ui/libs/product-signals/posthog') const { createOpenpanelAdapter } = await import('@proj-airi/stage-ui/libs/product-signals/openpanel')
return createPosthogAdapter(options) return createOpenpanelAdapter(options)
}) })
registerAuthorizationHandler(browserAuthorizationHandler) registerAuthorizationHandler(browserAuthorizationHandler)
+2 -2
View File
@@ -2,8 +2,8 @@ FROM node:24-trixie AS build-stage
WORKDIR /app WORKDIR /app
ARG VITE_ENABLE_POSTHOG=false ARG VITE_ENABLE_ANALYTICS=false
ENV VITE_ENABLE_POSTHOG=${VITE_ENABLE_POSTHOG} ENV VITE_ENABLE_ANALYTICS=${VITE_ENABLE_ANALYTICS}
RUN apt update && apt install -y ca-certificates curl RUN apt update && apt install -y ca-certificates curl
RUN update-ca-certificates RUN update-ca-certificates
-1
View File
@@ -83,7 +83,6 @@
"nprogress": "catalog:", "nprogress": "catalog:",
"onnxruntime-web": "catalog:", "onnxruntime-web": "catalog:",
"pinia": "catalog:", "pinia": "catalog:",
"posthog-js": "catalog:",
"rehype-stringify": "catalog:", "rehype-stringify": "catalog:",
"reka-ui": "catalog:", "reka-ui": "catalog:",
"remark-parse": "catalog:", "remark-parse": "catalog:",
+2 -2
View File
@@ -31,8 +31,8 @@ import './styles/main.css'
import 'uno.css' import 'uno.css'
configureAnalyticsAdapter(async (options) => { configureAnalyticsAdapter(async (options) => {
const { createPosthogAdapter } = await import('@proj-airi/stage-ui/libs/product-signals/posthog') const { createOpenpanelAdapter } = await import('@proj-airi/stage-ui/libs/product-signals/openpanel')
return createPosthogAdapter(options) return createOpenpanelAdapter(options)
}) })
registerAuthorizationHandler(browserAuthorizationHandler) registerAuthorizationHandler(browserAuthorizationHandler)
@@ -163,7 +163,7 @@ async function handleSubmit() {
} }
else { else {
await characterStore.create(payload) 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 // create path; clones from built-in presets would emit
// `character_type: 'built_in'` from wherever they get wired up. // `character_type: 'built_in'` from wherever they get wired up.
// `voice_enabled` reflects whether the user supplied a TTS voice id // `voice_enabled` reflects whether the user supplied a TTS voice id
+1 -1
View File
@@ -97,7 +97,7 @@ export default defineConfig({
chunkFileNames: (chunkInfo) => { chunkFileNames: (chunkInfo) => {
const containsAnalyticsModule = chunkInfo.moduleIds.some((moduleId) => { const containsAnalyticsModule = chunkInfo.moduleIds.some((moduleId) => {
const normalizedModuleId = moduleId.replaceAll('\\', '/').toLowerCase() 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; // Only analytics/provider chunks receive the manual neutral mapping;
+1 -1
View File
@@ -22,6 +22,7 @@
"@formkit/auto-animate": "catalog:", "@formkit/auto-animate": "catalog:",
"@moeru/eventa": "catalog:", "@moeru/eventa": "catalog:",
"@moeru/std": "catalog:", "@moeru/std": "catalog:",
"@openpanel/web": "catalog:",
"@proj-airi/font-chillroundm": "workspace:^", "@proj-airi/font-chillroundm": "workspace:^",
"@proj-airi/font-cjkfonts-allseto": "workspace:^", "@proj-airi/font-cjkfonts-allseto": "workspace:^",
"@proj-airi/font-xiaolai": "workspace:^", "@proj-airi/font-xiaolai": "workspace:^",
@@ -40,7 +41,6 @@
"nanoid": "catalog:", "nanoid": "catalog:",
"nprogress": "catalog:", "nprogress": "catalog:",
"pinia": "catalog:", "pinia": "catalog:",
"posthog-js": "catalog:",
"uuid": "catalog:", "uuid": "catalog:",
"vaul-vue": "catalog:", "vaul-vue": "catalog:",
"vue": "catalog:", "vue": "catalog:",
+3 -3
View File
@@ -24,10 +24,10 @@ import 'vue-sonner/style.css'
import './styles/main.css' import './styles/main.css'
import 'uno.css' import 'uno.css'
if (isEnvTruthy(import.meta.env.VITE_ENABLE_POSTHOG)) { if (isEnvTruthy(import.meta.env.VITE_ENABLE_ANALYTICS)) {
void loadAnalyticsAdapter(async () => { void loadAnalyticsAdapter(async () => {
const { createPosthogAdapter } = await import('./modules/analytics-adapters/posthog') const { createOpenpanelAdapter } = await import('./modules/analytics-adapters/openpanel')
return createPosthogAdapter() return createOpenpanelAdapter()
}) })
} }
@@ -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 })
},
}
}
@@ -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)
},
}
}
@@ -23,7 +23,7 @@ describe('auth analytics', () => {
// //
// The auth SPA emitted `signup_completed` before it knew the Better Auth // 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. // 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 // The anonymous UI milestone must use its own name. The identified server
// event remains the only canonical `signup_completed` business fact. // event remains the only canonical `signup_completed` business fact.
+1 -1
View File
@@ -63,7 +63,7 @@ export default defineConfig({
chunkFileNames: (chunkInfo) => { chunkFileNames: (chunkInfo) => {
const containsAnalyticsModule = chunkInfo.moduleIds.some((moduleId) => { const containsAnalyticsModule = chunkInfo.moduleIds.some((moduleId) => {
const normalizedModuleId = moduleId.replaceAll('\\', '/').toLowerCase() 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 // Keep analytics as the source-domain name, but explicitly map its
+83
View File
@@ -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.
+23
View File
@@ -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' })
}
-12
View File
@@ -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' })
}
+1 -1
View File
@@ -26,7 +26,7 @@ export default {
Layout, Layout,
enhanceApp({ app, siteData }) { enhanceApp({ app, siteData }) {
if (!import.meta.env.SSR && import.meta.env.PROD) { if (!import.meta.env.SSR && import.meta.env.PROD) {
import('../modules/posthog') import('../modules/openpanel')
} }
const i18n = createI18n({ const i18n = createI18n({
+1 -1
View File
@@ -18,6 +18,7 @@
"@fontsource/dm-mono": "catalog:", "@fontsource/dm-mono": "catalog:",
"@fontsource/dm-serif-display": "catalog:", "@fontsource/dm-serif-display": "catalog:",
"@moeru/std": "catalog:", "@moeru/std": "catalog:",
"@openpanel/web": "catalog:",
"@proj-airi/chromatic": "catalog:", "@proj-airi/chromatic": "catalog:",
"@proj-airi/i18n": "workspace:^", "@proj-airi/i18n": "workspace:^",
"@proj-airi/stage-shared": "workspace:^", "@proj-airi/stage-shared": "workspace:^",
@@ -28,7 +29,6 @@
"mark.js": "catalog:", "mark.js": "catalog:",
"motion-v": "catalog:", "motion-v": "catalog:",
"pathe": "catalog:", "pathe": "catalog:",
"posthog-js": "catalog:",
"reka-ui": "catalog:", "reka-ui": "catalog:",
"vue": "catalog:", "vue": "catalog:",
"vue-i18n": "catalog:", "vue-i18n": "catalog:",
-1
View File
@@ -78,7 +78,6 @@
"oxc-minify": "catalog:", "oxc-minify": "catalog:",
"oxlint": "catalog:", "oxlint": "catalog:",
"playwright": "catalog:", "playwright": "catalog:",
"posthog-js": "catalog:",
"publint": "catalog:", "publint": "catalog:",
"rollup": "catalog:", "rollup": "catalog:",
"simple-git-hooks": "catalog:", "simple-git-hooks": "catalog:",
-1
View File
@@ -51,7 +51,6 @@
"nanoid": "catalog:", "nanoid": "catalog:",
"node-vibrant": "catalog:", "node-vibrant": "catalog:",
"pinia": "catalog:", "pinia": "catalog:",
"posthog-js": "catalog:",
"reka-ui": "catalog:", "reka-ui": "catalog:",
"unspeech": "catalog:xsai", "unspeech": "catalog:xsai",
"vue": "catalog:", "vue": "catalog:",
-1
View File
@@ -47,7 +47,6 @@
"nanoid": "catalog:", "nanoid": "catalog:",
"node-vibrant": "catalog:", "node-vibrant": "catalog:",
"pinia": "catalog:", "pinia": "catalog:",
"posthog-js": "catalog:",
"reka-ui": "catalog:", "reka-ui": "catalog:",
"splitpanes": "catalog:", "splitpanes": "catalog:",
"unspeech": "catalog:xsai", "unspeech": "catalog:xsai",
@@ -282,10 +282,10 @@ onMounted(async () => {
await creditsRefresh.catch(() => undefined) 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 // settings page (already-authenticated users); when we add a public
// pricing landing page the entry-surface label changes but the event stays the // 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) { if (!fluxPurchaseDisabled) {
trackPaywallSeen({ trackPaywallSeen({
entry_surface: 'settings_flux', entry_surface: 'settings_flux',
@@ -308,7 +308,7 @@ async function handleBuy(stripePriceId: string) {
loadingPriceId.value = stripePriceId loadingPriceId.value = stripePriceId
checkoutReturnMessageActive.value = false checkoutReturnMessageActive.value = false
message.value = null 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 // the Stripe webhook (server-side `payment_completed`); we deliberately
// don't send a formatted-string price from the SPA so funnels don't get // don't send a formatted-string price from the SPA so funnels don't get
// poisoned by currency-formatting drift. // poisoned by currency-formatting drift.
@@ -330,9 +330,8 @@ async function handleBuy(stripePriceId: string) {
} }
const data = await res.json() const data = await res.json()
if (data.url) { if (data.url) {
// PostHog funnel step 3: about to redirect to Stripe. Capture before // Start capture before redirecting to Stripe so fetch keepalive can
// the page nav so the event is sent (PostHog's beforeunload handler // finish delivery after the page unloads.
// would otherwise race the navigation).
trackCheckoutStarted(stripePriceId, { trackCheckoutStarted(stripePriceId, {
currency: selectedCurrency.value, currency: selectedCurrency.value,
entry_surface: 'settings_flux', entry_surface: 'settings_flux',
+1 -2
View File
@@ -17,7 +17,7 @@
"exports": { "exports": {
".": "./src/index.ts", ".": "./src/index.ts",
"./auth": "./src/auth/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", "./beat-sync": "./src/beat-sync/index.ts",
"./global-shortcut": "./src/global-shortcut/index.ts", "./global-shortcut": "./src/global-shortcut/index.ts",
"./godot-stage": "./src/godot-stage/index.ts", "./godot-stage": "./src/godot-stage/index.ts",
@@ -37,7 +37,6 @@
"@vueuse/core": "catalog:", "@vueuse/core": "catalog:",
"gpuu": "catalog:", "gpuu": "catalog:",
"pinia": "catalog:", "pinia": "catalog:",
"posthog-js": "catalog:",
"valibot": "catalog:", "valibot": "catalog:",
"vue": "catalog:" "vue": "catalog:"
}, },
@@ -0,0 +1,5 @@
/** Public write configuration shared by AIRI client surfaces. Never add a client secret here. */
export const OPENPANEL_CONFIG = {
apiUrl: 'https://analytics.airi.build/api',
clientId: 'f930f223-4a46-4064-b919-1a5465dbc029',
} as const
@@ -1,25 +0,0 @@
import type { PostHogConfig } from 'posthog-js'
function isEnvFlagEnabled(value: string | undefined): boolean {
if (value == null)
return false
return /^(?:1|true|t|yes|y|on)$/i.test(value.trim())
}
/** Whether client analytics is enabled for the current Vite build. */
export const POSTHOG_ENABLED = isEnvFlagEnabled(import.meta.env.VITE_ENABLE_POSTHOG)
/** The shared PostHog project key used by every AIRI client surface. */
export const POSTHOG_PROJECT_KEY
= import.meta.env.VITE_POSTHOG_PROJECT_KEY
?? 'phc_pzjziJjrVZpa9SqnQqq0QEKvkmuCPH7GDTA6TbRTEf9' // cspell:disable-line
/** Shared PostHog defaults for AIRI single-page applications. */
export const DEFAULT_POSTHOG_CONFIG = {
api_host: 'https://t.airi.build',
// This preset captures page views on history changes. PostHog then also
// captures page leaves, which keeps route-level dwell time measurable.
defaults: '2025-05-24',
person_profiles: 'identified_only',
} as const satisfies Partial<PostHogConfig>
+1 -1
View File
@@ -75,6 +75,7 @@
"@huggingface/transformers": "catalog:", "@huggingface/transformers": "catalog:",
"@moeru/eventa": "catalog:", "@moeru/eventa": "catalog:",
"@moeru/std": "catalog:", "@moeru/std": "catalog:",
"@openpanel/web": "catalog:",
"@opentelemetry/api": "catalog:", "@opentelemetry/api": "catalog:",
"@opentelemetry/core": "catalog:", "@opentelemetry/core": "catalog:",
"@opentelemetry/sdk-trace-base": "catalog:", "@opentelemetry/sdk-trace-base": "catalog:",
@@ -150,7 +151,6 @@
"ofetch": "catalog:", "ofetch": "catalog:",
"pinia": "catalog:", "pinia": "catalog:",
"pinia-plugin-synced": "catalog:", "pinia-plugin-synced": "catalog:",
"posthog-js": "catalog:",
"rehype-katex": "catalog:", "rehype-katex": "catalog:",
"rehype-parse": "catalog:", "rehype-parse": "catalog:",
"rehype-stringify": "catalog:", "rehype-stringify": "catalog:",
@@ -9,7 +9,7 @@ const analyticsMocks = vi.hoisted(() => ({
isStageTamagotchiMock: vi.fn(() => false), isStageTamagotchiMock: vi.fn(() => false),
isAnalyticsAvailableInBuildMock: vi.fn(() => true), isAnalyticsAvailableInBuildMock: vi.fn(() => true),
recordFirstMessageMock: vi.fn(() => true), recordFirstMessageMock: vi.fn(() => true),
posthogCaptureMock: vi.fn(), captureMock: vi.fn(),
})) }))
vi.mock('@proj-airi/stage-shared', () => ({ vi.mock('@proj-airi/stage-shared', () => ({
@@ -29,14 +29,14 @@ vi.mock('vue-i18n', () => ({
})) }))
vi.mock('../libs/product-signals', () => ({ vi.mock('../libs/product-signals', () => ({
captureAnalyticsEvent: analyticsMocks.posthogCaptureMock, captureAnalyticsEvent: analyticsMocks.captureMock,
enableAnalytics: analyticsMocks.ensureAnalyticsInitializedMock, enableAnalytics: analyticsMocks.ensureAnalyticsInitializedMock,
getAnalytics: () => ({ getAnalytics: () => ({
emit: (event: { name: string }, payload: object, options?: object) => { emit: (event: { name: string }, payload: object, options?: object) => {
if (options) 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, recordFirstMessage: analyticsMocks.recordFirstMessageMock,
}), }),
@@ -58,7 +58,7 @@ vi.mock('../stores/settings/general', () => ({
describe('useAnalytics conversation product events', () => { describe('useAnalytics conversation product events', () => {
beforeEach(() => { beforeEach(() => {
analyticsMocks.posthogCaptureMock.mockClear() analyticsMocks.captureMock.mockClear()
analyticsMocks.recordFirstMessageMock.mockClear() analyticsMocks.recordFirstMessageMock.mockClear()
analyticsMocks.ensureAnalyticsInitializedMock.mockClear() analyticsMocks.ensureAnalyticsInitializedMock.mockClear()
analyticsMocks.isStageCapacitorMock.mockReset() analyticsMocks.isStageCapacitorMock.mockReset()
@@ -75,7 +75,7 @@ describe('useAnalytics conversation product events', () => {
reason: 'manual-chat', reason: 'manual-chat',
}) })
expect(analyticsMocks.posthogCaptureMock).toHaveBeenCalledWith('tts_stop_clicked', { expect(analyticsMocks.captureMock).toHaveBeenCalledWith('tts_stop_clicked', {
app_surface: 'web', app_surface: 'web',
reason: 'manual-chat', reason: 'manual-chat',
}) })
@@ -89,82 +89,13 @@ describe('useAnalytics conversation product events', () => {
was_speaking: true, was_speaking: true,
}) })
expect(analyticsMocks.posthogCaptureMock).toHaveBeenCalledWith('speech_mute_toggled', { expect(analyticsMocks.captureMock).toHaveBeenCalledWith('speech_mute_toggled', {
app_surface: 'web', app_surface: 'web',
muted: true, muted: true,
was_speaking: 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', () => { it('infers the mobile surface for capacitor conversation actions', () => {
analyticsMocks.isStageCapacitorMock.mockReturnValue(true) analyticsMocks.isStageCapacitorMock.mockReturnValue(true)
const analytics = useAnalytics() const analytics = useAnalytics()
@@ -175,7 +106,7 @@ describe('useAnalytics conversation product events', () => {
cloud_synced: true, cloud_synced: true,
}) })
expect(analyticsMocks.posthogCaptureMock).toHaveBeenCalledWith('chat_session_selected', { expect(analyticsMocks.captureMock).toHaveBeenCalledWith('chat_session_selected', {
app_surface: 'mobile', app_surface: 'mobile',
source: 'sessions_drawer', source: 'sessions_drawer',
message_count: 4, message_count: 4,
@@ -199,17 +130,17 @@ describe('useAnalytics conversation product events', () => {
source: 'history', source: 'history',
}) })
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(1, 'chat_message_deleted', { expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(1, 'chat_message_deleted', {
app_surface: 'electron', app_surface: 'electron',
source: 'history', source: 'history',
message_role: 'assistant', message_role: 'assistant',
}) })
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(2, 'chat_messages_cleared', { expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(2, 'chat_messages_cleared', {
app_surface: 'electron', app_surface: 'electron',
source: 'chat_controls', source: 'chat_controls',
message_count: 3, message_count: 3,
}) })
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(3, 'chat_message_retried', { expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(3, 'chat_message_retried', {
app_surface: 'electron', app_surface: 'electron',
source: 'history', source: 'history',
}) })
@@ -218,7 +149,7 @@ describe('useAnalytics conversation product events', () => {
/** /**
* @example * @example
* analytics.trackVoiceSelected({ tts_provider_id: 'official-provider', tts_model_id: 'stepfun/tts', voice_id: 'voice-1', voice_type: 'official_selected', source: 'settings' }) * 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', () => { it('emits TTS voice selection events without losing provider context', () => {
const analytics = useAnalytics() const analytics = useAnalytics()
@@ -250,7 +181,7 @@ describe('useAnalytics conversation product events', () => {
source: 'settings', source: 'settings',
}) })
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(1, 'tts_provider_selected', { expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(1, 'tts_provider_selected', {
app_surface: 'web', app_surface: 'web',
tts_provider_id: 'official-provider', tts_provider_id: 'official-provider',
tts_model_id: 'stepfun/tts', tts_model_id: 'stepfun/tts',
@@ -258,7 +189,7 @@ describe('useAnalytics conversation product events', () => {
trigger_method: 'selection', trigger_method: 'selection',
trigger_type: 'user_action', trigger_type: 'user_action',
}) })
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(2, 'voice_selected', { expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(2, 'voice_selected', {
app_surface: 'web', app_surface: 'web',
tts_provider_id: 'official-provider', tts_provider_id: 'official-provider',
tts_model_id: 'stepfun/tts', tts_model_id: 'stepfun/tts',
@@ -266,7 +197,7 @@ describe('useAnalytics conversation product events', () => {
voice_type: 'official_selected', voice_type: 'official_selected',
source: 'settings', source: 'settings',
}) })
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(3, 'voice_preview_played', { expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(3, 'voice_preview_played', {
app_surface: 'web', app_surface: 'web',
tts_provider_id: 'official-provider', tts_provider_id: 'official-provider',
tts_model_id: 'stepfun/tts', tts_model_id: 'stepfun/tts',
@@ -274,7 +205,7 @@ describe('useAnalytics conversation product events', () => {
voice_type: 'official_selected', voice_type: 'official_selected',
source: 'manual_preview', source: 'manual_preview',
}) })
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(4, 'voice_pack_bound', { expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(4, 'voice_pack_bound', {
app_surface: 'web', app_surface: 'web',
tts_provider_id: 'official-provider', tts_provider_id: 'official-provider',
tts_model_id: 'stepfun/tts', tts_model_id: 'stepfun/tts',
@@ -287,7 +218,7 @@ describe('useAnalytics conversation product events', () => {
/** /**
* @example * @example
* analytics.trackOfficialTtsExposed({ source: 'post_first_chat', tts_provider_id: 'official-provider-speech', tts_model_id: 'stepfun/tts' }) * 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', () => { it('emits official TTS activation funnel events', () => {
const analytics = useAnalytics() const analytics = useAnalytics()
@@ -312,13 +243,13 @@ describe('useAnalytics conversation product events', () => {
source: 'manual_preview', source: 'manual_preview',
duration_ms: 320, duration_ms: 320,
}) })
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(1, 'official_tts_exposed', { expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(1, 'official_tts_exposed', {
app_surface: 'web', app_surface: 'web',
tts_provider_id: 'official-provider-speech', tts_provider_id: 'official-provider-speech',
tts_model_id: 'stepfun/tts', tts_model_id: 'stepfun/tts',
source: 'post_first_chat', 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', app_surface: 'web',
tts_provider_id: 'official-provider-speech', tts_provider_id: 'official-provider-speech',
tts_model_id: 'stepfun/tts', tts_model_id: 'stepfun/tts',
@@ -326,7 +257,7 @@ describe('useAnalytics conversation product events', () => {
voice_type: 'official_selected', voice_type: 'official_selected',
source: 'manual_preview', source: 'manual_preview',
}) })
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(3, 'official_tts_preview_succeeded', { expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(3, 'official_tts_preview_succeeded', {
app_surface: 'web', app_surface: 'web',
tts_provider_id: 'official-provider-speech', tts_provider_id: 'official-provider-speech',
tts_model_id: 'stepfun/tts', tts_model_id: 'stepfun/tts',
@@ -346,7 +277,7 @@ describe('useAnalytics conversation product events', () => {
flux_balance_bucket: '1_100', flux_balance_bucket: '1_100',
}) })
expect(analyticsMocks.posthogCaptureMock).toHaveBeenCalledWith('paywall_seen', { expect(analyticsMocks.captureMock).toHaveBeenCalledWith('paywall_seen', {
app_surface: 'web', app_surface: 'web',
entry_surface: 'settings_flux', entry_surface: 'settings_flux',
reason: 'manual_topup', reason: 'manual_topup',
@@ -367,16 +298,16 @@ describe('useAnalytics conversation product events', () => {
entry_surface: 'settings_flux', entry_surface: 'settings_flux',
}) })
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(1, 'pricing_page_viewed', { expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(1, 'pricing_page_viewed', {
entry_surface: 'settings_flux', entry_surface: 'settings_flux',
plan_period: 'one_time', plan_period: 'one_time',
}) })
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(2, 'plan_selected', { expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(2, 'plan_selected', {
currency: 'USD', currency: 'USD',
entry_surface: 'settings_flux', entry_surface: 'settings_flux',
plan_id: 'price-1', plan_id: 'price-1',
}) })
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(3, 'checkout_started', { expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(3, 'checkout_started', {
currency: 'USD', currency: 'USD',
entry_surface: 'settings_flux', entry_surface: 'settings_flux',
plan_id: 'price-1', plan_id: 'price-1',
@@ -386,7 +317,7 @@ describe('useAnalytics conversation product events', () => {
/** /**
* @example * @example
* analytics.trackMicrophonePermissionDenied({ stt_provider_id: 'browser-web-speech-api' }) * 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', () => { it('emits one voice action and only user-relevant outcome events', () => {
const analytics = useAnalytics() const analytics = useAnalytics()
@@ -403,33 +334,33 @@ describe('useAnalytics conversation product events', () => {
duration_ms: 420, duration_ms: 420,
}) })
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(1, 'voice_input_started', { expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(1, 'voice_input_started', {
app_surface: 'web', app_surface: 'web',
stt_provider_id: 'browser-web-speech-api', stt_provider_id: 'browser-web-speech-api',
trigger_method: 'voice', trigger_method: 'voice',
trigger_type: 'user_action', trigger_type: 'user_action',
}) })
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(2, 'microphone_permission_denied', { expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(2, 'microphone_permission_denied', {
app_surface: 'web', app_surface: 'web',
stt_provider_id: 'browser-web-speech-api', stt_provider_id: 'browser-web-speech-api',
error_code: 'permission_denied', error_code: 'permission_denied',
trigger_method: 'voice', trigger_method: 'voice',
trigger_type: 'user_flow_result', trigger_type: 'user_flow_result',
}) })
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(3, 'voice_input_cancelled', { expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(3, 'voice_input_cancelled', {
app_surface: 'web', app_surface: 'web',
stt_provider_id: 'browser-web-speech-api', stt_provider_id: 'browser-web-speech-api',
duration_ms: 420, duration_ms: 420,
trigger_method: 'voice', trigger_method: 'voice',
trigger_type: 'user_flow_result', trigger_type: 'user_flow_result',
}) })
expect(analyticsMocks.posthogCaptureMock).toHaveBeenCalledTimes(3) expect(analyticsMocks.captureMock).toHaveBeenCalledTimes(3)
}) })
/** /**
* @example * @example
* analytics.trackProviderConnectionTestCompleted({ provider_id: 'openai-compatible', provider_mode: 'custom', success: false, error_code: 'validation_failed', duration_ms: 32 }) * 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', () => { it('emits one manual provider test action and one result', () => {
const analytics = useAnalytics() const analytics = useAnalytics()
@@ -445,14 +376,14 @@ describe('useAnalytics conversation product events', () => {
success: true, success: true,
}) })
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(1, 'provider_connection_test_started', { expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(1, 'provider_connection_test_started', {
app_surface: 'web', app_surface: 'web',
provider_id: 'official-provider', provider_id: 'official-provider',
provider_mode: 'official', provider_mode: 'official',
trigger_method: 'button', trigger_method: 'button',
trigger_type: 'user_action', 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', app_surface: 'web',
provider_id: 'official-provider', provider_id: 'official-provider',
provider_mode: 'official', provider_mode: 'official',
@@ -461,7 +392,7 @@ describe('useAnalytics conversation product events', () => {
trigger_method: 'button', trigger_method: 'button',
trigger_type: 'user_flow_result', 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', () => { 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.trackProviderClick('official-provider', 'consciousness')
analytics.trackModelSwitched('none', 'model-b') analytics.trackModelSwitched('none', 'model-b')
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(1, 'provider_card_clicked', { expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(1, 'provider_card_clicked', {
app_surface: 'web', app_surface: 'web',
provider_id: 'official-provider', provider_id: 'official-provider',
module: 'consciousness', module: 'consciousness',
trigger_method: 'provider_card', trigger_method: 'provider_card',
trigger_type: 'user_action', trigger_type: 'user_action',
}) })
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(2, 'model_switched', { expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(2, 'model_switched', {
app_surface: 'web', app_surface: 'web',
from_model: 'none', from_model: 'none',
to_model: 'model-b', to_model: 'model-b',
@@ -529,17 +460,17 @@ describe('useAnalytics conversation product events', () => {
success: true, success: true,
}) })
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(1, 'onboarding_started', { expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(1, 'onboarding_started', {
app_surface: 'web', app_surface: 'web',
entry: 'app_start', entry: 'app_start',
}) })
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(2, 'onboarding_completed', { expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(2, 'onboarding_completed', {
app_surface: 'web', app_surface: 'web',
selected_provider_type: 'official', selected_provider_type: 'official',
selected_provider_id: 'official-provider', selected_provider_id: 'official-provider',
selected_use_case: 'role_chat', selected_use_case: 'role_chat',
}) })
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(3, 'message_sent', { expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(3, 'message_sent', {
app_surface: 'web', app_surface: 'web',
conversation_id: 'session-1', conversation_id: 'session-1',
provider_type: 'official', provider_type: 'official',
@@ -555,18 +486,18 @@ describe('useAnalytics conversation product events', () => {
trigger_method: 'text_input', trigger_method: 'text_input',
trigger_type: 'user_action', trigger_type: 'user_action',
}) })
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(4, 'quota_limit_reached', { expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(4, 'quota_limit_reached', {
limit_type: 'flux', limit_type: 'flux',
current_usage: 0, current_usage: 0,
limit_value: 0, limit_value: 0,
entry: 'pricing', entry: 'pricing',
}) })
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(5, 'upgrade_clicked', { expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(5, 'upgrade_clicked', {
source_page: 'settings_flux', source_page: 'settings_flux',
current_plan: 'flux', current_plan: 'flux',
trigger: 'manual_topup', trigger: 'manual_topup',
}) })
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(6, 'feature_used', { expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(6, 'feature_used', {
app_surface: 'web', app_surface: 'web',
feature_name: 'chat', feature_name: 'chat',
business_domain: 'conversation', business_domain: 'conversation',
@@ -620,50 +551,50 @@ describe('useAnalytics conversation product events', () => {
category: 'payment', category: 'payment',
}) })
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(1, 'conversation_created', { expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(1, 'conversation_created', {
app_surface: 'web', app_surface: 'web',
conversation_id: 'session-1', conversation_id: 'session-1',
source: 'new_session', source: 'new_session',
character_id: 'character-1', character_id: 'character-1',
cloud_synced: true, cloud_synced: true,
}) })
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(2, 'conversation_renamed', { expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(2, 'conversation_renamed', {
app_surface: 'web', app_surface: 'web',
conversation_id: 'session-1', conversation_id: 'session-1',
source: 'sessions_drawer', source: 'sessions_drawer',
}) })
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(3, 'conversation_shared', { expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(3, 'conversation_shared', {
app_surface: 'web', app_surface: 'web',
conversation_id: 'session-1', conversation_id: 'session-1',
source: 'share_button', source: 'share_button',
}) })
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(4, 'conversation_deleted', { expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(4, 'conversation_deleted', {
app_surface: 'web', app_surface: 'web',
conversation_id: 'session-1', conversation_id: 'session-1',
message_count: 6, message_count: 6,
cloud_synced: true, cloud_synced: true,
}) })
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(5, 'attachment_uploaded', { expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(5, 'attachment_uploaded', {
app_surface: 'web', app_surface: 'web',
attachment_type: 'image', attachment_type: 'image',
size_bytes: 2048, size_bytes: 2048,
source: 'chat', source: 'chat',
success: true, success: true,
}) })
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(6, 'preset_used', { expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(6, 'preset_used', {
app_surface: 'web', app_surface: 'web',
preset_id: 'preset-live2d-1', preset_id: 'preset-live2d-1',
preset_type: 'stage_model', preset_type: 'stage_model',
source: 'settings', source: 'settings',
}) })
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(7, 'settings_changed', { expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(7, 'settings_changed', {
app_surface: 'web', app_surface: 'web',
setting_name: 'analytics_enabled', setting_name: 'analytics_enabled',
previous_value: false, previous_value: false,
new_value: true, new_value: true,
source: 'settings', source: 'settings',
}) })
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(8, 'support_contacted', { expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(8, 'support_contacted', {
app_surface: 'web', app_surface: 'web',
channel: 'discord', channel: 'discord',
source: 'settings', source: 'settings',
@@ -674,7 +605,7 @@ describe('useAnalytics conversation product events', () => {
/** /**
* @example * @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 }) * 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', () => { it('emits feedback and bug report events with community triage tags', () => {
const analytics = useAnalytics() const analytics = useAnalytics()
@@ -697,7 +628,7 @@ describe('useAnalytics conversation product events', () => {
entrypoint: 'community_manual_tag', entrypoint: 'community_manual_tag',
}) })
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(1, 'bug_report_submitted', { expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(1, 'bug_report_submitted', {
app_surface: 'web', app_surface: 'web',
source: 'app', source: 'app',
category: 'update', category: 'update',
@@ -708,7 +639,7 @@ describe('useAnalytics conversation product events', () => {
include_triage_context: true, include_triage_context: true,
screenshot_attached: true, screenshot_attached: true,
}) })
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(2, 'feedback_submitted', { expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(2, 'feedback_submitted', {
app_surface: 'web', app_surface: 'web',
source: 'discord', source: 'discord',
category: 'voice_input', category: 'voice_input',
@@ -728,20 +659,20 @@ describe('useAnalytics conversation product events', () => {
analytics.trackAccountDeletionRequested() analytics.trackAccountDeletionRequested()
analytics.trackOauthCallbackFailed({ stage: 'missing_flow_state' }) analytics.trackOauthCallbackFailed({ stage: 'missing_flow_state' })
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(1, 'password_changed', { app_surface: 'web' }) expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(1, 'password_changed', { app_surface: 'web' })
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(2, 'password_reset_requested', { app_surface: 'web' }) expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(2, 'password_reset_requested', { app_surface: 'web' })
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith( expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(
3, 3,
'oauth_provider_link_started', 'oauth_provider_link_started',
{ app_surface: 'web', provider: 'github' }, { app_surface: 'web', provider: 'github' },
{ beforeNavigation: true }, { beforeNavigation: true },
) )
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(4, 'oauth_provider_unlinked', { expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(4, 'oauth_provider_unlinked', {
app_surface: 'web', app_surface: 'web',
provider: 'google', provider: 'google',
}) })
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(5, 'account_deletion_requested', { app_surface: 'web' }) expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(5, 'account_deletion_requested', { app_surface: 'web' })
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(6, 'oauth_callback_failed', { expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(6, 'oauth_callback_failed', {
app_surface: 'web', app_surface: 'web',
stage: 'missing_flow_state', stage: 'missing_flow_state',
}) })
@@ -755,21 +686,21 @@ describe('useAnalytics conversation product events', () => {
analytics.trackSceneBackgroundSet({ source: 'scene_settings', cleared: true }) analytics.trackSceneBackgroundSet({ source: 'scene_settings', cleared: true })
analytics.trackCharacterUpdated({ character_id: 'character-1' }) analytics.trackCharacterUpdated({ character_id: 'character-1' })
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(1, 'card_edited', { expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(1, 'card_edited', {
app_surface: 'web', app_surface: 'web',
card_id: 'card-1', card_id: 'card-1',
}) })
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(2, 'scene_background_set', { expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(2, 'scene_background_set', {
app_surface: 'web', app_surface: 'web',
source: 'card_gallery', source: 'card_gallery',
cleared: false, cleared: false,
}) })
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(3, 'scene_background_set', { expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(3, 'scene_background_set', {
app_surface: 'web', app_surface: 'web',
source: 'scene_settings', source: 'scene_settings',
cleared: true, cleared: true,
}) })
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(4, 'character_updated', { expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(4, 'character_updated', {
character_id: 'character-1', character_id: 'character-1',
}) })
}) })
@@ -780,11 +711,11 @@ describe('useAnalytics conversation product events', () => {
analytics.trackDataAction({ action: 'chats_exported' }) analytics.trackDataAction({ action: 'chats_exported' })
analytics.trackDataAction({ action: 'app_data_cleared' }) analytics.trackDataAction({ action: 'app_data_cleared' })
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(1, 'data_action', { expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(1, 'data_action', {
app_surface: 'web', app_surface: 'web',
action: 'chats_exported', action: 'chats_exported',
}) })
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(2, 'data_action', { expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(2, 'data_action', {
app_surface: 'web', app_surface: 'web',
action: 'app_data_cleared', action: 'app_data_cleared',
}) })
@@ -797,11 +728,11 @@ describe('useAnalytics conversation product events', () => {
analytics.trackControlsIslandAction({ action: 'toggle_chat' }) analytics.trackControlsIslandAction({ action: 'toggle_chat' })
analytics.trackControlsIslandAction({ action: 'refresh_window' }) analytics.trackControlsIslandAction({ action: 'refresh_window' })
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(1, 'controls_island_action', { expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(1, 'controls_island_action', {
action: 'toggle_chat', action: 'toggle_chat',
environment: 'tamagotchi', environment: 'tamagotchi',
}) })
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith( expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(
2, 2,
'controls_island_action', 'controls_island_action',
{ {
@@ -826,19 +757,19 @@ describe('useAnalytics conversation product events', () => {
analytics.trackMcpConnectionTestRun({ success: false }) analytics.trackMcpConnectionTestRun({ success: false })
analytics.trackDevicePairingQrShown() analytics.trackDevicePairingQrShown()
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(1, 'spotlight_used', {}) expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(1, 'spotlight_used', {})
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(2, 'widget_opened', { widget_id: 'weather' }) expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(2, 'widget_opened', { widget_id: 'weather' })
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(3, 'update_check_clicked', { channel: 'auto' }) expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(3, 'update_check_clicked', { channel: 'auto' })
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(4, 'update_downloaded', { channel: 'stable', version: '0.11.0' }) expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(4, 'update_downloaded', { channel: 'stable', version: '0.11.0' })
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith( expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(
5, 5,
'update_install_clicked', 'update_install_clicked',
{ channel: 'stable', version: '0.11.0' }, { channel: 'stable', version: '0.11.0' },
{ beforeNavigation: true }, { beforeNavigation: true },
) )
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(6, 'mcp_server_updated', { action: 'add' }) expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(6, 'mcp_server_updated', { action: 'add' })
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(7, 'mcp_server_updated', { action: 'remove' }) expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(7, 'mcp_server_updated', { action: 'remove' })
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(8, 'mcp_connection_test_run', { success: false }) expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(8, 'mcp_connection_test_run', { success: false })
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(9, 'device_pairing_qr_shown', {}) expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(9, 'device_pairing_qr_shown', {})
}) })
}) })
@@ -159,7 +159,7 @@ export function useAnalytics() {
* *
* Expects: * Expects:
* - `entry_surface` is a stable identifier don't rename without coordinating * - `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') { function trackPricingViewed(entrySurface: string, planPeriod?: 'monthly' | 'annual' | 'one_time') {
if (!canCapture()) if (!canCapture())
@@ -187,7 +187,7 @@ export function useAnalytics() {
* `window.location.href = ...`. `beforeNavigation` lets the installed * `window.location.href = ...`. `beforeNavigation` lets the installed
* adapter choose a delivery mechanism that survives document unload. * 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 * server-side by the product-events service, keyed by the Better Auth
* user id. * user id.
*/ */
@@ -233,7 +233,7 @@ export function useAnalytics() {
} }
// ─── Account lifecycle (same event names as apps/ui-server-auth's // ─── 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() { function trackPasswordChanged() {
if (!canCapture()) if (!canCapture())
@@ -308,7 +308,7 @@ export function useAnalytics() {
captureAnalyticsEvent('character_created', properties) 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) { function trackVoiceModeActivated(characterId?: string) {
if (!canCapture()) if (!canCapture())
return return
@@ -336,7 +336,7 @@ export function useAnalytics() {
/** /**
* Retention cohort denominator every chat session start. Pair with * Retention cohort denominator every chat session start. Pair with
* `payment_completed` cohort to compute "active paying user" retention * `payment_completed` cohort to compute "active paying user" retention
* curves in PostHog. * curves in OpenPanel.
*/ */
function trackChatSessionStarted(modelId: string, sessionIndex?: number) { function trackChatSessionStarted(modelId: string, sessionIndex?: number) {
if (!canCapture()) if (!canCapture())
@@ -344,49 +344,6 @@ export function useAnalytics() {
captureAnalyticsEvent('chat_session_started', { model_id: modelId, ...(sessionIndex != null && { session_index: sessionIndex }) }) 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). */ /** Closing event for one full message round (user send → assistant render). */
function trackMessageRound(properties: ChatRoundCorrelationProperties & { function trackMessageRound(properties: ChatRoundCorrelationProperties & {
duration_ms: number duration_ms: number
@@ -1047,7 +1004,6 @@ export function useAnalytics() {
trackModelSwitched, trackModelSwitched,
trackChatSessionStarted, trackChatSessionStarted,
trackAiGeneration,
trackMessageRound, trackMessageRound,
trackMessageRoundFailed, trackMessageRoundFailed,
trackMessageSent, trackMessageSent,
+13 -13
View File
@@ -4,7 +4,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useAuthStore } from '../stores/auth' import { useAuthStore } from '../stores/auth'
import { authedFetch } from './auth-fetch' import { authedFetch } from './auth-fetch'
const posthogMocks = vi.hoisted(() => ({ const analyticsMocks = vi.hoisted(() => ({
getAnalyticsIdentitySnapshot: vi.fn<() => { distinctId: string, sessionId: string } | null>(() => ({ getAnalyticsIdentitySnapshot: vi.fn<() => { distinctId: string, sessionId: string } | null>(() => ({
distinctId: 'distinct-1', distinctId: 'distinct-1',
sessionId: 'session-1', sessionId: 'session-1',
@@ -12,7 +12,7 @@ const posthogMocks = vi.hoisted(() => ({
})) }))
vi.mock('./product-signals', () => ({ vi.mock('./product-signals', () => ({
getAnalyticsIdentitySnapshot: posthogMocks.getAnalyticsIdentitySnapshot, getAnalyticsIdentitySnapshot: analyticsMocks.getAnalyticsIdentitySnapshot,
})) }))
describe('authedFetch', () => { describe('authedFetch', () => {
@@ -20,13 +20,13 @@ describe('authedFetch', () => {
vi.restoreAllMocks() vi.restoreAllMocks()
setActivePinia(createPinia()) setActivePinia(createPinia())
useAuthStore().token = 'access-token' useAuthStore().token = 'access-token'
posthogMocks.getAnalyticsIdentitySnapshot.mockReturnValue({ analyticsMocks.getAnalyticsIdentitySnapshot.mockReturnValue({
distinctId: 'distinct-1', distinctId: 'distinct-1',
sessionId: 'session-1', sessionId: 'session-1',
}) })
}) })
it('sends PostHog identity headers with authenticated API requests', async () => { it('sends analytics identity headers with authenticated API requests', async () => {
const fetchMock = vi.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise<Response>>(async () => new Response('{}', { status: 200 })) const fetchMock = vi.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise<Response>>(async () => new Response('{}', { status: 200 }))
vi.stubGlobal('fetch', fetchMock) vi.stubGlobal('fetch', fetchMock)
@@ -37,12 +37,12 @@ describe('authedFetch', () => {
const headers = fetchMock.mock.calls[0]?.[1]?.headers const headers = fetchMock.mock.calls[0]?.[1]?.headers
expect(headers).toBeInstanceOf(Headers) expect(headers).toBeInstanceOf(Headers)
expect((headers as Headers).get('Authorization')).toBe('Bearer access-token') 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-openpanel-device-id')).toBe('distinct-1')
expect((headers as Headers).get('x-posthog-session-id')).toBe('session-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 () => { it('omits analytics identity headers when analytics has no active identity', async () => {
posthogMocks.getAnalyticsIdentitySnapshot.mockReturnValue(null) analyticsMocks.getAnalyticsIdentitySnapshot.mockReturnValue(null)
const fetchMock = vi.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise<Response>>(async () => new Response('{}', { status: 200 })) const fetchMock = vi.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise<Response>>(async () => new Response('{}', { status: 200 }))
vi.stubGlobal('fetch', fetchMock) vi.stubGlobal('fetch', fetchMock)
@@ -51,11 +51,11 @@ describe('authedFetch', () => {
const headers = fetchMock.mock.calls[0]?.[1]?.headers const headers = fetchMock.mock.calls[0]?.[1]?.headers
expect(headers).toBeInstanceOf(Headers) expect(headers).toBeInstanceOf(Headers)
expect((headers as Headers).get('Authorization')).toBe('Bearer access-token') 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-openpanel-device-id')).toBeNull()
expect((headers as Headers).get('x-posthog-session-id')).toBeNull() expect((headers as Headers).get('x-openpanel-session-id')).toBeNull()
}) })
it('does not send PostHog identity headers to non-server origins', async () => { it('does not send analytics identity headers to non-server origins', async () => {
const fetchMock = vi.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise<Response>>(async () => new Response('{}', { status: 200 })) const fetchMock = vi.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise<Response>>(async () => new Response('{}', { status: 200 }))
vi.stubGlobal('fetch', fetchMock) vi.stubGlobal('fetch', fetchMock)
@@ -64,7 +64,7 @@ describe('authedFetch', () => {
const headers = fetchMock.mock.calls[0]?.[1]?.headers const headers = fetchMock.mock.calls[0]?.[1]?.headers
expect(headers).toBeInstanceOf(Headers) expect(headers).toBeInstanceOf(Headers)
expect((headers as Headers).get('Authorization')).toBe('Bearer access-token') 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-openpanel-device-id')).toBeNull()
expect((headers as Headers).get('x-posthog-session-id')).toBeNull() expect((headers as Headers).get('x-openpanel-session-id')).toBeNull()
}) })
}) })
+6 -6
View File
@@ -27,11 +27,11 @@ export async function authedFetch(
const headers = new Headers(init?.headers) const headers = new Headers(init?.headers)
if (token) if (token)
headers.set('Authorization', `Bearer ${token}`) headers.set('Authorization', `Bearer ${token}`)
const posthogIdentity = shouldAttachPosthogIdentity(input) ? getAnalyticsIdentitySnapshot() : null const openpanelIdentity = shouldAttachOpenpanelIdentity(input) ? getAnalyticsIdentitySnapshot() : null
if (posthogIdentity) { if (openpanelIdentity) {
headers.set('x-posthog-distinct-id', posthogIdentity.distinctId) headers.set('x-openpanel-device-id', openpanelIdentity.distinctId)
if (posthogIdentity.sessionId) if (openpanelIdentity.sessionId)
headers.set('x-posthog-session-id', posthogIdentity.sessionId) headers.set('x-openpanel-session-id', openpanelIdentity.sessionId)
} }
return fetch(input, { ...init, headers, credentials: 'omit' }) return fetch(input, { ...init, headers, credentials: 'omit' })
} }
@@ -59,7 +59,7 @@ export async function authedFetch(
return retried return retried
} }
function shouldAttachPosthogIdentity(input: RequestInfo | URL): boolean { function shouldAttachOpenpanelIdentity(input: RequestInfo | URL): boolean {
const url = typeof input === 'string' const url = typeof input === 'string'
? input ? input
: input instanceof URL ? input.toString() : input.url : input instanceof URL ? input.toString() : input.url
@@ -186,7 +186,7 @@ export function configureAnalyticsAdapter(loader: AnalyticsAdapterLoader): void
} }
export function isAnalyticsAvailableInBuild(): boolean { export function isAnalyticsAvailableInBuild(): boolean {
return isEnvTruthy(import.meta.env.VITE_ENABLE_POSTHOG) return isEnvTruthy(import.meta.env.VITE_ENABLE_ANALYTICS)
} }
export function enableAnalyticsCapture(): boolean { export function enableAnalyticsCapture(): boolean {
@@ -1,13 +0,0 @@
import { defineEvent } from '../../../utils/dsl'
export const aiGenerationEvent = defineEvent<{
conversation_id: string
round_id: string
provider_type: 'official' | 'custom' | 'unknown'
provider_id: string
model_id: string
usage_source: 'reported' | 'estimated' | 'unavailable'
input_tokens?: number
output_tokens?: number
total_tokens?: number
}>('$ai_generation')
@@ -1,3 +1,2 @@
export * from './generation'
export * from './message' export * from './message'
export * from './round' export * from './round'
@@ -2,7 +2,7 @@ import type { AnalyticsRecorder } from '../../index'
import { describe, expect, it, vi } from 'vitest' import { describe, expect, it, vi } from 'vitest'
import { aiGenerationEvent, messageSentEvent } from './events' import { messageRoundEvent, messageSentEvent } from './events'
import { createChatAnalyticsHooks } from './runtime' import { createChatAnalyticsHooks } from './runtime'
function createRecorder(): AnalyticsRecorder { function createRecorder(): AnalyticsRecorder {
@@ -76,44 +76,34 @@ describe('createChatAnalyticsHooks', () => {
expect(analytics.recordFirstMessage).toHaveBeenCalledOnce() 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 analytics = createRecorder()
const hooks = createChatAnalyticsHooks({ const hooks = createChatAnalyticsHooks({ analytics, getSessionMessages: () => [] })
analytics,
getSessionMessages: () => [],
})
hooks.onLlmGeneration?.({ expect(hooks).not.toHaveProperty('onLlmGeneration')
hooks.onMessageRound?.({
conversationId: 'session-1', conversationId: 'session-1',
roundId: 'round-1', roundId: 'round-1',
turnIndex: 1, turnIndex: 1,
model: 'custom-model', model: 'custom-model',
provider: 'custom-provider', durationMs: 120,
hasVoice: false,
inputTokens: 12, inputTokens: 12,
outputTokens: 8, outputTokens: 8,
totalTokens: 20, totalTokens: 20,
usageSource: 'reported', 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).toHaveBeenCalledTimes(1)
expect(analytics.emit).toHaveBeenCalledWith(aiGenerationEvent, { expect(analytics.emit).toHaveBeenCalledWith(messageRoundEvent, expect.objectContaining({
conversation_id: 'session-1', conversation_id: 'session-1',
round_id: 'round-1', round_id: 'round-1',
provider_type: 'custom', model: 'custom-model',
provider_id: 'custom-provider', duration_ms: 120,
model_id: 'custom-model',
usage_source: 'reported',
input_tokens: 12, input_tokens: 12,
output_tokens: 8, output_tokens: 8,
total_tokens: 20, total_tokens: 20,
}) usage_source: 'reported',
}))
}) })
}) })
@@ -5,7 +5,6 @@ import type { AnalyticsRecorder } from '../../index'
import { getAnalytics } from '../../index' import { getAnalytics } from '../../index'
import { import {
aiGenerationEvent,
messageRoundEvent, messageRoundEvent,
messageRoundFailedEvent, messageRoundFailedEvent,
messageSentEvent, messageSentEvent,
@@ -14,7 +13,6 @@ import { getProviderMode } from './types'
type ChatAnalyticsCallbacks = Pick< type ChatAnalyticsCallbacks = Pick<
ChatOrchestratorRuntimeDeps, ChatOrchestratorRuntimeDeps,
| 'onLlmGeneration'
| 'onMessageRound' | 'onMessageRound'
| 'onMessageRoundFailed' | 'onMessageRoundFailed'
| 'onTrackFirstMessage' | 'onTrackFirstMessage'
@@ -40,23 +38,6 @@ export function createChatAnalyticsHooks(options: CreateChatAnalyticsHooksOption
return { return {
onTrackFirstMessage: () => analytics.recordFirstMessage(), 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 }) => { onMessageRound: ({ conversationId, roundId, turnIndex, durationMs, hasVoice, model, inputTokens, outputTokens, totalTokens, usageSource }) => {
analytics.emit(messageRoundEvent, { analytics.emit(messageRoundEvent, {
conversation_id: conversationId, conversation_id: conversationId,
@@ -0,0 +1,65 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createOpenpanelAdapter } from './openpanel'
afterEach(() => {
vi.restoreAllMocks()
})
describe('openPanel browser adapter', () => {
it('drops disabled events and isolates account identity across logout', async () => {
const requests: RequestInit[] = []
const destinations: string[] = []
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => {
destinations.push(String(input))
if (init)
requests.push(init)
return new Response(JSON.stringify({ deviceId: 'server-device', sessionId: 'session-1' }), { status: 200 })
})
const adapter = createOpenpanelAdapter({ enabled: false })
expect(adapter.capture('disabled_event', {})).toBe(false)
expect(adapter.getIdentitySnapshot()).toBeNull()
expect(adapter.setCaptureEnabled(true)).toBe(true)
const firstDevice = adapter.getIdentitySnapshot()?.distinctId
expect(firstDevice).toBeTruthy()
adapter.identify('alice')
adapter.capture('checkout_started', {}, { beforeNavigation: true })
await vi.waitFor(() => expect(requests.some(request => String(request.body).includes('checkout_started'))).toBe(true))
const checkout = requests.find(request => String(request.body).includes('checkout_started'))!
expect(checkout.keepalive).toBe(true)
expect(JSON.parse(String(checkout.body)).payload.profileId).toBe('alice')
expect(JSON.parse(String(checkout.body)).payload.properties.__deviceId).toBe(firstDevice)
const originalUrl = window.location.href
history.replaceState(null, '', `${window.location.pathname}?code=private#fragment`)
await vi.waitFor(() => expect(requests.some(request => String(request.body).includes('screen_view'))).toBe(true))
await new Promise(resolve => setTimeout(resolve, 80))
adapter.capture('navigation_metadata', {})
await vi.waitFor(() => expect(requests.some(request => String(request.body).includes('navigation_metadata'))).toBe(true))
const navigation = requests.find(request => String(request.body).includes('navigation_metadata'))!
expect(String(navigation.body)).not.toContain('code=private')
expect(String(navigation.body)).not.toContain('#fragment')
expect(JSON.parse(String(navigation.body)).payload.properties.__path).toBe(originalUrl.split(/[?#]/, 1)[0])
expect(requests.some(request => String(request.body).includes('code=private'))).toBe(false)
adapter.resetIdentity()
expect(adapter.getIdentitySnapshot()?.distinctId).not.toBe(firstDevice)
adapter.identify('bob')
adapter.capture('new_account', {})
await vi.waitFor(() => expect(requests.some(request => String(request.body).includes('new_account'))).toBe(true))
const bob = requests.find(request => String(request.body).includes('new_account'))!
expect(JSON.parse(String(bob.body)).payload.profileId).toBe('bob')
expect(JSON.parse(String(bob.body)).payload.properties.__deviceId).not.toBe(firstDevice)
adapter.setCaptureEnabled(false)
history.replaceState(null, '', originalUrl)
adapter.capture('disabled_event', {})
adapter.setCaptureEnabled(true)
adapter.capture('enabled_event', {})
await vi.waitFor(() => expect(requests.some(request => String(request.body).includes('enabled_event'))).toBe(true))
expect(requests.some(request => String(request.body).includes('disabled_event'))).toBe(false)
expect(destinations.every(url => url.startsWith('https://analytics.airi.build/api/'))).toBe(true)
expect(requests.every(request => !new Headers(request.headers).has('openpanel-client-secret'))).toBe(true)
adapter.setCaptureEnabled(false)
})
})
@@ -0,0 +1,116 @@
import type { AnalyticsAdapter, AnalyticsAdapterOptions } from './client'
import { OpenPanel } from '@openpanel/web'
import { isStageCapacitor, isStageTamagotchi } from '@proj-airi/stage-shared'
import { OPENPANEL_CONFIG } from '@proj-airi/stage-shared/analytics/openpanel'
const deviceStorageKey = 'airi:openpanel-device-id'
function loadDeviceId(): string {
try {
const stored = sessionStorage.getItem(deviceStorageKey)
if (stored)
return stored
}
catch {
// Storage can be unavailable in embedded browsers. Keep this visit in memory.
}
return rotateDeviceId()
}
function rotateDeviceId(): string {
const id = crypto.randomUUID()
try {
sessionStorage.setItem(deviceStorageKey, id)
}
catch {
// The in-memory identity still isolates accounts when storage is unavailable.
}
return id
}
/** Sends product events to OpenPanel under the current consent and identity state. */
export function createOpenpanelAdapter(options: AnalyticsAdapterOptions): AnalyticsAdapter {
let enabled = options.enabled
// Rotate the device on logout. Clearing SDK fields alone reuses its
// server-derived fingerprint and can link two accounts in one browser.
let deviceId = enabled ? loadDeviceId() : undefined
const panel = new OpenPanel({
...OPENPANEL_CONFIG,
// Consent must drop events. The SDK's disabled option queues them instead.
filter(payload) {
if (!enabled)
return false
// OAuth codes and other query values must not enter analytics.
if (payload.type === 'track' && payload.payload.properties) {
for (const key of ['__path', '__referrer']) {
const value = payload.payload.properties[key]
if (typeof value === 'string')
payload.payload.properties[key] = value.split(/[?#]/, 1)[0]
}
}
return true
},
trackScreenViews: true,
trackOutgoingLinks: false,
trackAttributes: false,
})
panel.setGlobalProperties({
app_surface: isStageTamagotchi() ? 'electron' : isStageCapacitor() ? 'mobile' : 'web',
__deviceId: deviceId,
})
return {
capture(name, properties) {
if (!enabled)
return false
// The SDK uses fetch keepalive for normal events, including navigation.
void panel.track(name, { ...properties, __deviceId: deviceId }).catch(() => console.warn('[analytics] Product event delivery failed'))
return true
},
getIdentitySnapshot() {
if (!enabled)
return null
if (!deviceId)
return null
return { distinctId: deviceId }
},
identify(userId) {
if (!enabled)
return
panel.identify({ profileId: userId })
},
registerBuildInfo(buildInfo) {
panel.setGlobalProperties({
app_branch: buildInfo.branch,
app_build_time: buildInfo.builtOn,
app_commit: buildInfo.commit,
app_version: buildInfo.version && buildInfo.version !== '0.0.0' ? buildInfo.version : 'dev',
})
},
resetIdentity() {
panel.clear()
deviceId = enabled ? rotateDeviceId() : undefined
panel.setGlobalProperties({ __deviceId: deviceId })
},
setCaptureEnabled(value) {
enabled = value
if (!value) {
panel.clear()
deviceId = undefined
try {
sessionStorage.removeItem(deviceStorageKey)
}
catch {
// Capture remains disabled even when browser storage is unavailable.
}
}
else if (!deviceId) {
deviceId = rotateDeviceId()
}
panel.setGlobalProperties({ __deviceId: deviceId })
return enabled
},
}
}
@@ -1,52 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
import { createPosthogAdapter } from './posthog'
const posthogMocks = vi.hoisted(() => ({
capture: vi.fn(),
get_distinct_id: vi.fn(() => 'distinct-1'),
get_session_id: vi.fn(() => 'session-1'),
has_opted_out_capturing: vi.fn(() => false),
init: vi.fn(),
register: vi.fn(),
}))
vi.mock('posthog-js', () => ({ default: posthogMocks }))
vi.mock('@proj-airi/stage-shared', () => ({
isStageCapacitor: () => false,
isStageTamagotchi: () => false,
}))
vi.mock('@proj-airi/stage-shared/analytics/posthog', () => ({
DEFAULT_POSTHOG_CONFIG: {},
POSTHOG_PROJECT_KEY: 'test-project-key',
}))
describe('posthog analytics adapter', () => {
it('registers the runtime under the dedicated app_surface property', () => {
createPosthogAdapter({ enabled: true })
expect(posthogMocks.register).toHaveBeenCalledWith({ app_surface: 'web' })
})
it('exposes the current provider identity for server-side conversion linking', () => {
const adapter = createPosthogAdapter({ enabled: true })
expect(adapter.getIdentitySnapshot()).toEqual({
distinctId: 'distinct-1',
sessionId: 'session-1',
})
})
it('maps the provider-neutral navigation hint to unload-safe delivery', () => {
const adapter = createPosthogAdapter({ enabled: true })
adapter.capture('checkout_started', { plan_id: 'monthly' }, { beforeNavigation: true })
expect(posthogMocks.capture).toHaveBeenCalledWith(
'checkout_started',
{ plan_id: 'monthly' },
{ send_instantly: true, transport: 'sendBeacon' },
)
})
})
@@ -1,79 +0,0 @@
import type { AboutBuildInfo } from '../../components/scenarios/about/types'
import type { AnalyticsAdapter, AnalyticsAdapterOptions } from './client'
import posthog from 'posthog-js'
import { isStageCapacitor, isStageTamagotchi } from '@proj-airi/stage-shared'
import {
DEFAULT_POSTHOG_CONFIG,
POSTHOG_PROJECT_KEY,
} from '@proj-airi/stage-shared/analytics/posthog'
/** Creates and initializes the default PostHog adapter. */
export function createPosthogAdapter(options: AnalyticsAdapterOptions): AnalyticsAdapter {
posthog.init(POSTHOG_PROJECT_KEY, {
...DEFAULT_POSTHOG_CONFIG,
opt_out_capturing_by_default: !options.enabled,
})
posthog.register({ app_surface: currentSurface() })
return {
capture(name, properties, captureOptions) {
if (posthog.has_opted_out_capturing())
return false
posthog.capture(
name,
{ ...properties },
captureOptions?.beforeNavigation
? { send_instantly: true, transport: 'sendBeacon' }
: undefined,
)
return true
},
getIdentitySnapshot() {
if (posthog.has_opted_out_capturing())
return null
const distinctId = posthog.get_distinct_id()
if (!distinctId)
return null
const sessionId = posthog.get_session_id()
return { distinctId, ...(sessionId && { sessionId }) }
},
identify(userId) {
if (!posthog.has_opted_out_capturing())
posthog.identify(userId)
},
registerBuildInfo(buildInfo: AboutBuildInfo) {
posthog.register({
app_branch: buildInfo.branch,
app_build_time: buildInfo.builtOn,
app_commit: buildInfo.commit,
app_version: (buildInfo.version && buildInfo.version !== '0.0.0') ? buildInfo.version : 'dev',
})
},
resetIdentity() {
posthog.reset()
},
setCaptureEnabled(enabled) {
if (enabled) {
if (posthog.has_opted_out_capturing())
posthog.opt_in_capturing()
return true
}
if (!posthog.has_opted_out_capturing())
posthog.opt_out_capturing()
return false
},
}
}
function currentSurface(): 'electron' | 'mobile' | 'web' {
if (isStageTamagotchi())
return 'electron'
if (isStageCapacitor())
return 'mobile'
return 'web'
}
@@ -49,7 +49,6 @@ const ioTracerMocks = vi.hoisted(() => {
const llmStreamMock = vi.fn() const llmStreamMock = vi.fn()
const trackFirstMessageMock = vi.fn() const trackFirstMessageMock = vi.fn()
const chatAnalyticsMocks = vi.hoisted(() => ({ const chatAnalyticsMocks = vi.hoisted(() => ({
trackAiGeneration: vi.fn(),
trackMessageRound: vi.fn(), trackMessageRound: vi.fn(),
trackMessageRoundFailed: vi.fn(), trackMessageRoundFailed: vi.fn(),
trackMessageSent: vi.fn(), trackMessageSent: vi.fn(),
@@ -99,9 +98,6 @@ vi.mock('../libs/product-signals', () => ({
getAnalytics: () => ({ getAnalytics: () => ({
emit: (event: { name: string }, properties: unknown) => { emit: (event: { name: string }, properties: unknown) => {
switch (event.name) { switch (event.name) {
case '$ai_generation':
chatAnalyticsMocks.trackAiGeneration(properties)
break
case 'message_round': case 'message_round':
chatAnalyticsMocks.trackMessageRound(properties) chatAnalyticsMocks.trackMessageRound(properties)
break break
@@ -450,7 +446,7 @@ describe('chat store contract', () => {
expect(chatAnalyticsMocks.trackMessageRound).toHaveBeenCalledWith(expect.objectContaining(correlation)) 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) => { llmStreamMock.mockImplementation(async (_model: string, _chatProvider: ChatProvider, _messages: Message[], options: any) => {
await options.onStreamEvent({ type: 'text-delta', text: 'ok' }) await options.onStreamEvent({ type: 'text-delta', text: 'ok' })
await options.onStreamEvent({ type: 'finish', finishReason: 'stop' }) await options.onStreamEvent({ type: 'finish', finishReason: 'stop' })
@@ -468,26 +464,26 @@ describe('chat store contract', () => {
chatProvider: provider, chatProvider: provider,
}) })
expect(chatAnalyticsMocks.trackAiGeneration).toHaveBeenCalledWith({ expect(chatAnalyticsMocks.trackMessageRound).toHaveBeenCalledTimes(1)
conversation_id: 'session-1', expect(chatAnalyticsMocks.trackMessageRound).toHaveBeenCalledWith(expect.objectContaining({
round_id: expect.any(String), model: 'gpt-test',
provider_type: 'custom',
provider_id: 'mock-provider',
model_id: 'gpt-test',
usage_source: 'reported', usage_source: 'reported',
input_tokens: 12, input_tokens: 12,
output_tokens: 8, output_tokens: 8,
total_tokens: 20, total_tokens: 20,
}) }))
chatAnalyticsMocks.trackAiGeneration.mockClear()
activeProviderRef.value = 'official-provider' activeProviderRef.value = 'official-provider'
await store.ingest('official turn', { await store.ingest('official turn', {
model: 'chat-auto', model: 'chat-auto',
chatProvider: provider, 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({ expect(llmStreamMock.mock.calls[1]?.[3]?.headers).toEqual({
[AIRI_CHAT_APP_SURFACE_HEADER]: 'web', [AIRI_CHAT_APP_SURFACE_HEADER]: 'web',
[AIRI_CHAT_SESSION_ID_HEADER]: 'session-1', [AIRI_CHAT_SESSION_ID_HEADER]: 'session-1',
@@ -525,7 +521,7 @@ describe('chat store contract', () => {
// ROOT CAUSE: // ROOT CAUSE:
// //
// One successful send emitted both the canonical message/latency events // 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. // distinct product decision.
it('does not emit redundant generic chat aliases for a successful send', async () => { it('does not emit redundant generic chat aliases for a successful send', async () => {
llmStreamMock.mockImplementation(async (_model: string, _chatProvider: ChatProvider, _messages: Message[], options: any) => { llmStreamMock.mockImplementation(async (_model: string, _chatProvider: ChatProvider, _messages: Message[], options: any) => {
+85 -111
View File
@@ -264,6 +264,12 @@ catalogs:
'@nekopaw/tempora': '@nekopaw/tempora':
specifier: 0.4.0-alpha.1 specifier: 0.4.0-alpha.1
version: 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': '@opentelemetry/api':
specifier: ^1.9.1 specifier: ^1.9.1
version: 1.9.1 version: 1.9.1
@@ -927,12 +933,6 @@ catalogs:
postgres: postgres:
specifier: ^3.4.9 specifier: ^3.4.9
version: 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: postprocessing:
specifier: ^6.39.4 specifier: ^6.39.4
version: 6.39.4 version: 6.39.4
@@ -1329,9 +1329,6 @@ importers:
playwright: playwright:
specifier: 'catalog:' specifier: 'catalog:'
version: 1.62.1 version: 1.62.1
posthog-js:
specifier: 'catalog:'
version: 1.306.1
publint: publint:
specifier: 'catalog:' specifier: 'catalog:'
version: 0.3.24 version: 0.3.24
@@ -1648,9 +1645,6 @@ importers:
pinia: pinia:
specifier: 'catalog:' specifier: 'catalog:'
version: 4.0.3(@vue/devtools-api@8.2.1)(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3)) 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: rehype-stringify:
specifier: 'catalog:' specifier: 'catalog:'
version: 10.0.1 version: 10.0.1
@@ -2093,9 +2087,6 @@ importers:
popmotion: popmotion:
specifier: 'catalog:' specifier: 'catalog:'
version: 11.0.5 version: 11.0.5
posthog-js:
specifier: 'catalog:'
version: 1.306.1
rehype-stringify: rehype-stringify:
specifier: 'catalog:' specifier: 'catalog:'
version: 10.0.1 version: 10.0.1
@@ -2569,9 +2560,6 @@ importers:
pinia: pinia:
specifier: 'catalog:' specifier: 'catalog:'
version: 4.0.3(@vue/devtools-api@8.2.1)(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3)) 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: rehype-stringify:
specifier: 'catalog:' specifier: 'catalog:'
version: 10.0.1 version: 10.0.1
@@ -2783,6 +2771,9 @@ importers:
'@moeru/std': '@moeru/std':
specifier: 'catalog:' specifier: 'catalog:'
version: 0.1.0-beta.17 version: 0.1.0-beta.17
'@openpanel/web':
specifier: 'catalog:'
version: 1.4.1
'@proj-airi/font-chillroundm': '@proj-airi/font-chillroundm':
specifier: workspace:^ specifier: workspace:^
version: link:../../packages/font-chillroundm version: link:../../packages/font-chillroundm
@@ -2837,9 +2828,6 @@ importers:
pinia: pinia:
specifier: 'catalog:' specifier: 'catalog:'
version: 4.0.3(@vue/devtools-api@8.2.1)(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3)) 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: uuid:
specifier: 'catalog:' specifier: 'catalog:'
version: 14.0.2 version: 14.0.2
@@ -2970,6 +2958,9 @@ importers:
'@moeru/std': '@moeru/std':
specifier: 'catalog:' specifier: 'catalog:'
version: 0.1.0-beta.17 version: 0.1.0-beta.17
'@openpanel/web':
specifier: 'catalog:'
version: 1.4.1
'@proj-airi/chromatic': '@proj-airi/chromatic':
specifier: 'catalog:' specifier: 'catalog:'
version: 1.1.4 version: 1.1.4
@@ -3000,9 +2991,6 @@ importers:
pathe: pathe:
specifier: 'catalog:' specifier: 'catalog:'
version: 2.0.3 version: 2.0.3
posthog-js:
specifier: 'catalog:'
version: 1.306.1
reka-ui: reka-ui:
specifier: 'catalog:' specifier: 'catalog:'
version: 2.10.3(vue@3.5.41(typescript@6.0.3)) version: 2.10.3(vue@3.5.41(typescript@6.0.3))
@@ -4149,9 +4137,6 @@ importers:
pinia: pinia:
specifier: 'catalog:' specifier: 'catalog:'
version: 4.0.3(@vue/devtools-api@8.2.1)(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3)) 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: reka-ui:
specifier: 'catalog:' specifier: 'catalog:'
version: 2.10.3(vue@3.5.41(typescript@6.0.3)) version: 2.10.3(vue@3.5.41(typescript@6.0.3))
@@ -4279,9 +4264,6 @@ importers:
pinia: pinia:
specifier: 'catalog:' specifier: 'catalog:'
version: 4.0.3(@vue/devtools-api@8.2.1)(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3)) 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: reka-ui:
specifier: 'catalog:' specifier: 'catalog:'
version: 2.10.3(vue@3.5.41(typescript@6.0.3)) version: 2.10.3(vue@3.5.41(typescript@6.0.3))
@@ -4349,9 +4331,6 @@ importers:
pinia: pinia:
specifier: 'catalog:' specifier: 'catalog:'
version: 4.0.3(@vue/devtools-api@8.2.1)(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3)) 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: valibot:
specifier: 'catalog:' specifier: 'catalog:'
version: 1.4.2(typescript@6.0.3) version: 1.4.2(typescript@6.0.3)
@@ -4395,6 +4374,9 @@ importers:
'@moeru/std': '@moeru/std':
specifier: 'catalog:' specifier: 'catalog:'
version: 0.1.0-beta.17 version: 0.1.0-beta.17
'@openpanel/web':
specifier: 'catalog:'
version: 1.4.1
'@opentelemetry/api': '@opentelemetry/api':
specifier: 'catalog:' specifier: 'catalog:'
version: 1.9.1 version: 1.9.1
@@ -4620,9 +4602,6 @@ importers:
pinia-plugin-synced: pinia-plugin-synced:
specifier: 'catalog:' 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)) 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: rehype-katex:
specifier: 'catalog:' specifier: 'catalog:'
version: 7.0.1 version: 7.0.1
@@ -5568,6 +5547,9 @@ importers:
'@moeru/std': '@moeru/std':
specifier: 'catalog:' specifier: 'catalog:'
version: 0.1.0-beta.17 version: 0.1.0-beta.17
'@openpanel/sdk':
specifier: 'catalog:'
version: 1.3.1
'@opentelemetry/api': '@opentelemetry/api':
specifier: 'catalog:' specifier: 'catalog:'
version: 1.9.1 version: 1.9.1
@@ -5658,9 +5640,6 @@ importers:
pg: pg:
specifier: 'catalog:' specifier: 'catalog:'
version: 8.23.0 version: 8.23.0
posthog-node:
specifier: 'catalog:'
version: 5.50.0(rxjs@7.8.2)
stripe: stripe:
specifier: 'catalog:' specifier: 'catalog:'
version: 22.5.0(@types/node@26.2.0) version: 22.5.0(@types/node@26.2.0)
@@ -8658,6 +8637,12 @@ packages:
'@one-ini/wasm@0.1.1': '@one-ini/wasm@0.1.1':
resolution: {integrity: sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==} 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': '@opentelemetry/api-logs@0.221.0':
resolution: {integrity: sha512-OlanaW1vv7ufTqQ3/fPLI4arGt5ZoM+P8abOMki6uEYnpRazepSWDwDnnw+la7kE26SHVC18//SMccrDvLKOXQ==} resolution: {integrity: sha512-OlanaW1vv7ufTqQ3/fPLI4arGt5ZoM+P8abOMki6uEYnpRazepSWDwDnnw+la7kE26SHVC18//SMccrDvLKOXQ==}
engines: {node: '>=8.0.0'} engines: {node: '>=8.0.0'}
@@ -10545,15 +10530,6 @@ packages:
'@polka/url@1.0.0-next.29': '@polka/url@1.0.0-next.29':
resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} 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': '@prisma/client@5.22.0':
resolution: {integrity: sha512-M0SVXfyHnQREBKxCgyo7sffrKttwE6R8PMq330MIUF0pTwjUhLbW84pFDlf06B27XyCR++VtjugEnIHdr07SVA==} resolution: {integrity: sha512-M0SVXfyHnQREBKxCgyo7sffrKttwE6R8PMq330MIUF0pTwjUhLbW84pFDlf06B27XyCR++VtjugEnIHdr07SVA==}
engines: {node: '>=16.13'} engines: {node: '>=16.13'}
@@ -10930,6 +10906,12 @@ packages:
cpu: [x64] cpu: [x64]
os: [win32] 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': '@sapphire/async-queue@1.5.5':
resolution: {integrity: sha512-cvGzxbba6sav2zZkH8GPf2oGk9yYoD5qrNWdu9fRehifgnFZJMV+nuy2nON2roRO4yQQ+v7MK/Pktl/HgfsUXg==} resolution: {integrity: sha512-cvGzxbba6sav2zZkH8GPf2oGk9yYoD5qrNWdu9fRehifgnFZJMV+nuy2nON2roRO4yQQ+v7MK/Pktl/HgfsUXg==}
engines: {node: '>=v14.0.0', npm: '>=7.0.0'} engines: {node: '>=v14.0.0', npm: '>=7.0.0'}
@@ -11309,6 +11291,9 @@ packages:
'@types/cors@2.8.19': '@types/cors@2.8.19':
resolution: {integrity: sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==} 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': '@types/culori@4.0.1':
resolution: {integrity: sha512-43M51r/22CjhbOXyGT361GZ9vncSVQ39u62x5eJdBQFviI8zWp2X5jzqg7k4M6PVgDQAClpy2bUe2dtwEgEDVQ==} resolution: {integrity: sha512-43M51r/22CjhbOXyGT361GZ9vncSVQ39u62x5eJdBQFviI8zWp2X5jzqg7k4M6PVgDQAClpy2bUe2dtwEgEDVQ==}
@@ -12431,6 +12416,9 @@ packages:
'@xsai/utils-chat@0.5.0': '@xsai/utils-chat@0.5.0':
resolution: {integrity: sha512-z6HZHngL1q9Uasc8L0uQkeGXc7UzqY5LSTc2eYW7EjEgFK4WjGt7iTo0TM+p/ruoQHKy8AAigMkmUQcuGvwPlA==} 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': '@yuku-codegen/binding-android-arm64@0.8.7':
resolution: {integrity: sha512-C/0zV5IhgVdYhGJTwrY0v8dknxlhiKwtVJkMUaexu9/QvRmzlV4vfU3hZlUSgqc2BxQHntL1mCVbDq8j0FRFDw==} resolution: {integrity: sha512-C/0zV5IhgVdYhGJTwrY0v8dknxlhiKwtVJkMUaexu9/QvRmzlV4vfU3hZlUSgqc2BxQHntL1mCVbDq8j0FRFDw==}
cpu: [arm64] cpu: [arm64]
@@ -13525,9 +13513,6 @@ packages:
resolution: {integrity: sha512-XGpFGbMLHwSt74YLTKho7Ib242qi6O8MSX+sRokV4oz7iKXvQWGYZthjIhjRGMxjzVkAubBO512dKGYcefmX3Q==} resolution: {integrity: sha512-XGpFGbMLHwSt74YLTKho7Ib242qi6O8MSX+sRokV4oz7iKXvQWGYZthjIhjRGMxjzVkAubBO512dKGYcefmX3Q==}
engines: {node: '>=6.4.0'} engines: {node: '>=6.4.0'}
core-js@3.50.0:
resolution: {integrity: sha512-BRWgOLKkFeCgRudR6zrs8p9XJZcE14grzKMMssoYrk6krtuEZ7MTKPIY5RzOnqsEKIR9kst7wNzphttraT+Yqw==}
core-util-is@1.0.2: core-util-is@1.0.2:
resolution: {integrity: sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==} resolution: {integrity: sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==}
@@ -14854,9 +14839,6 @@ packages:
resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==}
engines: {node: ^12.20 || >= 14.13} engines: {node: ^12.20 || >= 14.13}
fflate@0.4.9:
resolution: {integrity: sha512-zdxgIEddhfsyCaWpJ2SdXEP8ZMrKJ6+5jl4OupODcywU0IhRk6gdXuVGcPICyfx2H97hVK7xmJtRLPjkxAX8Vw==}
fflate@0.6.11: fflate@0.6.11:
resolution: {integrity: sha512-3JyEFWGjFn7zHmoa9+zG1BmW7X2okcmAB+0Cnu9UFbVs/jCBnl2A8o065ZlXiw145K3eBM3uLuzrYXC0RK7eDg==} resolution: {integrity: sha512-3JyEFWGjFn7zHmoa9+zG1BmW7X2okcmAB+0Cnu9UFbVs/jCBnl2A8o065ZlXiw145K3eBM3uLuzrYXC0RK7eDg==}
@@ -16548,6 +16530,9 @@ packages:
resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==} resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==}
engines: {node: '>= 18'} engines: {node: '>= 18'}
mitt@3.0.1:
resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==}
mkcert@3.2.0: mkcert@3.2.0:
resolution: {integrity: sha512-026Eivq9RoOjOuLJGzbhGwXUAjBxRX11Z7Jbm4/7lqT/Av+XNy9SPrJte6+UpEt7i+W3e/HZYxQqlQcqXZWSzg==} resolution: {integrity: sha512-026Eivq9RoOjOuLJGzbhGwXUAjBxRX11Z7Jbm4/7lqT/Av+XNy9SPrJte6+UpEt7i+W3e/HZYxQqlQcqXZWSzg==}
engines: {node: '>=16'} engines: {node: '>=16'}
@@ -17266,18 +17251,6 @@ packages:
resolution: {integrity: sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw==} resolution: {integrity: sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw==}
engines: {node: '>=12'} 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: postject@1.0.0-alpha.6:
resolution: {integrity: sha512-b9Eb8h2eVqNE8edvKdwqkrY6O7kAwmI8kcnBv1NScolYJbo59XUF0noFq+lxbC1yN20bmC0WBEbDC5H/7ASb0A==} resolution: {integrity: sha512-b9Eb8h2eVqNE8edvKdwqkrY6O7kAwmI8kcnBv1NScolYJbo59XUF0noFq+lxbC1yN20bmC0WBEbDC5H/7ASb0A==}
engines: {node: '>=14.0.0'} engines: {node: '>=14.0.0'}
@@ -17299,14 +17272,6 @@ packages:
resolution: {integrity: sha512-ZlsFlG7MtSFCoc5xreOvBAozCJ6Pf06opgJjh9ONEv418xpZSAzNjstD36C6+JwOnfSqOW/9uDkqKjezTdxZhw==} resolution: {integrity: sha512-ZlsFlG7MtSFCoc5xreOvBAozCJ6Pf06opgJjh9ONEv418xpZSAzNjstD36C6+JwOnfSqOW/9uDkqKjezTdxZhw==}
engines: {node: '>=20'} 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: prebuild-install@7.1.3:
resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==} resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==}
engines: {node: '>=10'} engines: {node: '>=10'}
@@ -17845,6 +17810,15 @@ packages:
resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==}
engines: {node: '>= 18'} 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: run-applescript@7.1.0:
resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==} resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -19600,9 +19574,6 @@ packages:
resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==}
engines: {node: '>= 8'} engines: {node: '>= 8'}
web-vitals@4.2.4:
resolution: {integrity: sha512-r4DIlprAGwJ7YM11VZp4R884m0Vmgr6EAKe3P+kO0PPj3Unqyvv59rczf6UiGcb9Z8QxZVcqKNwv/g0WNdWwsw==}
webcrypto-core@1.9.2: webcrypto-core@1.9.2:
resolution: {integrity: sha512-gsXecm82UQNlTBURJGuqOWy1Ww08S3kZUcr3aOJS02Pk0xLtkfeUAVC0u0xhgdonFme80edSJUIJyuvL/7250Q==} resolution: {integrity: sha512-gsXecm82UQNlTBURJGuqOWy1Ww08S3kZUcr3aOJS02Pk0xLtkfeUAVC0u0xhgdonFme80edSJUIJyuvL/7250Q==}
@@ -23109,6 +23080,14 @@ snapshots:
'@one-ini/wasm@0.1.1': {} '@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': '@opentelemetry/api-logs@0.221.0':
dependencies: dependencies:
'@opentelemetry/api': 1.9.1 '@opentelemetry/api': 1.9.1
@@ -24674,16 +24653,6 @@ snapshots:
'@polka/url@1.0.0-next.29': {} '@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': {} '@prisma/client@5.22.0': {}
'@proj-airi/chromatic@1.1.4': '@proj-airi/chromatic@1.1.4':
@@ -24947,6 +24916,10 @@ snapshots:
'@rollup/rollup-win32-x64-msvc@4.62.5': '@rollup/rollup-win32-x64-msvc@4.62.5':
optional: true optional: true
'@rrweb/types@2.0.0-alpha.20': {}
'@rrweb/utils@2.1.1': {}
'@sapphire/async-queue@1.5.5': {} '@sapphire/async-queue@1.5.5': {}
'@sapphire/shapeshift@4.0.0': '@sapphire/shapeshift@4.0.0':
@@ -25309,6 +25282,8 @@ snapshots:
dependencies: dependencies:
'@types/node': 26.2.0 '@types/node': 26.2.0
'@types/css-font-loading-module@0.0.7': {}
'@types/culori@4.0.1': {} '@types/culori@4.0.1': {}
'@types/d3-array@3.2.2': {} '@types/d3-array@3.2.2': {}
@@ -26910,6 +26885,8 @@ snapshots:
dependencies: dependencies:
'@xsai/shared-chat': 0.5.0 '@xsai/shared-chat': 0.5.0
'@xstate/fsm@1.6.5': {}
'@yuku-codegen/binding-android-arm64@0.8.7': '@yuku-codegen/binding-android-arm64@0.8.7':
optional: true optional: true
@@ -27888,8 +27865,6 @@ snapshots:
dependencies: dependencies:
browserslist: 4.28.8 browserslist: 4.28.8
core-js@3.50.0: {}
core-util-is@1.0.2: {} core-util-is@1.0.2: {}
core-util-is@1.0.3: {} core-util-is@1.0.3: {}
@@ -29347,8 +29322,6 @@ snapshots:
node-domexception: 1.0.0 node-domexception: 1.0.0
web-streams-polyfill: 3.3.3 web-streams-polyfill: 3.3.3
fflate@0.4.9: {}
fflate@0.6.11: {} fflate@0.6.11: {}
fflate@0.8.3: {} fflate@0.8.3: {}
@@ -31557,6 +31530,8 @@ snapshots:
dependencies: dependencies:
minipass: 7.1.3 minipass: 7.1.3
mitt@3.0.1: {}
mkcert@3.2.0: mkcert@3.2.0:
dependencies: dependencies:
commander: 11.1.0 commander: 11.1.0
@@ -32429,22 +32404,6 @@ snapshots:
postgres@3.4.9: {} 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: postject@1.0.0-alpha.6:
dependencies: dependencies:
commander: 9.5.0 commander: 9.5.0
@@ -32460,8 +32419,6 @@ snapshots:
powershell-utils@0.2.0: {} powershell-utils@0.2.0: {}
preact@10.29.8: {}
prebuild-install@7.1.3: prebuild-install@7.1.3:
dependencies: dependencies:
detect-libc: 2.1.2 detect-libc: 2.1.2
@@ -33174,6 +33131,25 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - 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-applescript@7.1.0: {}
run-parallel@1.2.0: run-parallel@1.2.0:
@@ -35077,8 +35053,6 @@ snapshots:
web-streams-polyfill@3.3.3: {} web-streams-polyfill@3.3.3: {}
web-vitals@4.2.4: {}
webcrypto-core@1.9.2: webcrypto-core@1.9.2:
dependencies: dependencies:
'@peculiar/asn1-schema': 2.9.4 '@peculiar/asn1-schema': 2.9.4
+2 -2
View File
@@ -129,6 +129,8 @@ catalog:
'@moeru/three-mmd-physics-ammo': 0.2.0-beta.2 '@moeru/three-mmd-physics-ammo': 0.2.0-beta.2
'@napi-rs/image': ^1.14.0 '@napi-rs/image': ^1.14.0
'@nekopaw/tempora': 0.4.0-alpha.1 '@nekopaw/tempora': 0.4.0-alpha.1
'@openpanel/sdk': 1.3.1
'@openpanel/web': 1.4.1
'@opentelemetry/api': ^1.9.1 '@opentelemetry/api': ^1.9.1
'@opentelemetry/api-logs': ^0.221.0 '@opentelemetry/api-logs': ^0.221.0
'@opentelemetry/auto-instrumentations-node': ^0.79.0 '@opentelemetry/auto-instrumentations-node': ^0.79.0
@@ -353,8 +355,6 @@ catalog:
popmotion: ^11.0.5 popmotion: ^11.0.5
postcss: ^8.5.26 postcss: ^8.5.26
postgres: ^3.4.9 postgres: ^3.4.9
posthog-js: 1.306.1
posthog-node: ^5.50.0
postprocessing: ^6.39.4 postprocessing: ^6.39.4
prismarine-block: ^1.23.0 prismarine-block: ^1.23.0
prismarine-entity: ^2.6.0 prismarine-entity: ^2.6.0
+1 -1
View File
@@ -27,6 +27,7 @@
"@langfuse/tracing": "catalog:", "@langfuse/tracing": "catalog:",
"@moeru/eventa": "catalog:", "@moeru/eventa": "catalog:",
"@moeru/std": "catalog:", "@moeru/std": "catalog:",
"@openpanel/sdk": "catalog:",
"@opentelemetry/api": "catalog:", "@opentelemetry/api": "catalog:",
"@opentelemetry/api-logs": "catalog:", "@opentelemetry/api-logs": "catalog:",
"@opentelemetry/exporter-logs-otlp-proto": "catalog:", "@opentelemetry/exporter-logs-otlp-proto": "catalog:",
@@ -57,7 +58,6 @@
"nanoid": "catalog:", "nanoid": "catalog:",
"ofetch": "catalog:", "ofetch": "catalog:",
"pg": "catalog:", "pg": "catalog:",
"posthog-node": "catalog:",
"stripe": "catalog:", "stripe": "catalog:",
"unspeech": "catalog:xsai", "unspeech": "catalog:xsai",
"valibot": "catalog:", "valibot": "catalog:",
-1
View File
@@ -28,7 +28,6 @@ function createTestDeps() {
providerCatalogService: {} as never, providerCatalogService: {} as never,
productEventService: { productEventService: {
track: vi.fn(async () => undefined), track: vi.fn(async () => undefined),
trackGeneration: vi.fn(async () => undefined),
} as never, } as never,
configKV: { getOrThrow: vi.fn() } as never, configKV: { getOrThrow: vi.fn() } as never,
redis: redis as never, redis: redis as never,
+10 -16
View File
@@ -60,7 +60,7 @@ import { createStripeRoutes } from './routes/stripe'
import { createVoicePackRoutes } from './routes/voice-packs' import { createVoicePackRoutes } from './routes/voice-packs'
import { createConfigKVService } from './services/adapters/config-kv' import { createConfigKVService } from './services/adapters/config-kv'
import { createConfigKVStore } from './services/adapters/config-kv/store' 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 { createBillingService } from './services/domain/billing/billing-service'
import { createFluxMeter } from './services/domain/billing/flux-meter' import { createFluxMeter } from './services/domain/billing/flux-meter'
import { createCharacterService } from './services/domain/characters' import { createCharacterService } from './services/domain/characters'
@@ -529,27 +529,21 @@ export async function createApp() {
build: ({ dependsOn }) => createConfigKVService(createConfigKVStore(dependsOn.db, dependsOn.redis)), build: ({ dependsOn }) => createConfigKVService(createConfigKVStore(dependsOn.db, dependsOn.redis)),
}) })
const posthogSink = injeca.provide('services:posthogSink', { const openpanelSink = injeca.provide('services:openpanelSink', {
dependsOn: { env: parsedEnv, lifecycle }, dependsOn: { env: parsedEnv },
// 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 '').
build: ({ dependsOn }) => { 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 return null
if (!apiUrl || !clientId || !clientSecret)
const sink = createPosthogSink({ throw new Error('OpenPanel requires API URL, client id, and client secret')
projectKey: dependsOn.env.POSTHOG_PROJECT_KEY, return createOpenpanelSink({ apiUrl, clientId, clientSecret })
host: dependsOn.env.POSTHOG_API_HOST,
})
dependsOn.lifecycle.appHooks.onStop(() => sink.shutdown())
return sink
}, },
}) })
const productEventService = injeca.provide('services:productEvents', { const productEventService = injeca.provide('services:productEvents', {
dependsOn: { posthogSink }, dependsOn: { openpanelSink },
build: ({ dependsOn }) => createProductEventService(dependsOn.posthogSink), build: ({ dependsOn }) => createProductEventService(dependsOn.openpanelSink),
}) })
const characterService = injeca.provide('services:characters', { const characterService = injeca.provide('services:characters', {
+3 -7
View File
@@ -104,13 +104,9 @@ const EnvSchema = object({
OTEL_TRACES_SAMPLING_RATIO: optionalNumberFromString(1, 'OTEL_TRACES_SAMPLING_RATIO', 0, 1), OTEL_TRACES_SAMPLING_RATIO: optionalNumberFromString(1, 'OTEL_TRACES_SAMPLING_RATIO', 0, 1),
PORT: optionalIntegerFromString(3000, 'PORT', 1), PORT: optionalIntegerFromString(3000, 'PORT', 1),
POSTHOG_API_HOST: optional(string(), 'https://t.airi.build'), OPENPANEL_API_URL: optional(string()),
// PostHog product-event forwarding for server-confirmed funnel facts. OPENPANEL_CLIENT_ID: optional(string()),
// Defaults to the shared AIRI project key (same browser-safe phc_* key the OPENPANEL_CLIENT_SECRET: optional(string()),
// 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
REDIS_URL: pipe(string(), nonEmpty('REDIS_URL is required')), REDIS_URL: pipe(string(), nonEmpty('REDIS_URL is required')),
STRIPE_SECRET_KEY: optional(string()), STRIPE_SECRET_KEY: optional(string()),
@@ -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_SESSION_ID_HEADER = 'x-airi-session-id'
export const AIRI_CHAT_ROUND_ID_HEADER = 'x-airi-round-id' export const AIRI_CHAT_ROUND_ID_HEADER = 'x-airi-round-id'
export const AIRI_CHAT_APP_SURFACE_HEADER = 'x-airi-app-surface' export const AIRI_CHAT_APP_SURFACE_HEADER = 'x-airi-app-surface'
const CLIENT_CHAT_ANALYTICS_SURFACES = new Set<AiGenerationAppSurface>(['web', 'mobile', 'electron']) const CLIENT_CHAT_ANALYTICS_SURFACES = new Set<ChatAppSurface>(['web', 'mobile', 'electron'])
/** /**
* Resolves the product runtime from a trusted client hint. * Resolves the product runtime from a trusted client hint.
* *
* Unknown values are not coerced to `server`: `$ai_generation` uses * Unknown values stay absent rather than being attributed to a client runtime.
* `capture_surface` for the process that emitted the event, while
* `app_surface` stays reserved for the user's actual product runtime.
*/ */
export function resolveChatAnalyticsSurface(value: string | undefined): AiGenerationAppSurface | undefined { export function resolveChatAnalyticsSurface(value: string | undefined): ChatAppSurface | undefined {
if (CLIENT_CHAT_ANALYTICS_SURFACES.has(value as AiGenerationAppSurface)) if (CLIENT_CHAT_ANALYTICS_SURFACES.has(value as ChatAppSurface))
return value as AiGenerationAppSurface return value as ChatAppSurface
return undefined return undefined
} }
@@ -1,6 +1,6 @@
import type { CapabilityAliasRoute } from '../../../../../schemas/provider-catalog' import type { CapabilityAliasRoute } from '../../../../../schemas/provider-catalog'
import type { UsageInfo } from '../../../../../services/domain/billing/billing' 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 { GatewayCallback } from '../../gateway'
import type { V1RouteDeps } from '../../types' import type { V1RouteDeps } from '../../types'
@@ -22,24 +22,10 @@ export interface ChatCompletionsOperationRequest {
body: Record<string, unknown> body: Record<string, unknown>
sessionId?: string sessionId?: string
roundId?: string roundId?: string
appSurface?: AiGenerationAppSurface appSurface?: ChatAppSurface
abortSignal?: AbortSignal 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'> { export function chatCompletions(deps: V1RouteDeps): GatewayCallback<'chat.completions'> {
const logger = useLogger('v1-completions').useGlobalConfig() const logger = useLogger('v1-completions').useGlobalConfig()
const telemetry = createRouteTelemetry({ const telemetry = createRouteTelemetry({
@@ -157,11 +143,7 @@ export function chatCompletions(deps: V1RouteDeps): GatewayCallback<'chat.comple
durationMs, durationMs,
requestId, requestId,
userId: input.userId, userId: input.userId,
sessionId: input.sessionId,
roundId: input.roundId,
appSurface: input.appSurface,
requestModel, requestModel,
generationModel: langfuseModel,
routeCtxProvider: routeCtx.provider, routeCtxProvider: routeCtx.provider,
billing, billing,
billingPolicy, billingPolicy,
@@ -178,11 +160,7 @@ export function chatCompletions(deps: V1RouteDeps): GatewayCallback<'chat.comple
durationMs, durationMs,
requestId, requestId,
userId: input.userId, userId: input.userId,
sessionId: input.sessionId,
roundId: input.roundId,
appSurface: input.appSurface,
requestModel, requestModel,
generationModel: langfuseModel,
routeCtxProvider: routeCtx.provider, routeCtxProvider: routeCtx.provider,
billing, billing,
billingPolicy, billingPolicy,
@@ -196,37 +174,6 @@ interface ChatModelAliasPlan {
modelIds: string[] 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<ChatModelAliasPlan> { async function resolveChatModelAliasPlan(deps: V1RouteDeps, aliasId: string): Promise<ChatModelAliasPlan> {
const alias = await deps.providerCatalogService.resolveEnabledAlias('llm', aliasId) const alias = await deps.providerCatalogService.resolveEnabledAlias('llm', aliasId)
const primaryRoutes = alias.routes.filter(route => route.pool === 'primary') const primaryRoutes = alias.routes.filter(route => route.pool === 'primary')
@@ -323,11 +270,7 @@ function streamChatCompletion(input: {
durationMs: number durationMs: number
requestId: string requestId: string
userId: string userId: string
sessionId?: string
roundId?: string
appSurface?: AiGenerationAppSurface
requestModel: string requestModel: string
generationModel: string
routeCtxProvider: string routeCtxProvider: string
billing: ChatBilling billing: ChatBilling
billingPolicy: ChatBillingPolicy 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 }) 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) // Debit flux via DB transaction (source of truth)
// NOTICE: streaming response is already sent, so we cannot reject on failure. // NOTICE: streaming response is already sent, so we cannot reject on failure.
// Log at error level so unpaid usage is visible in monitoring/alerts. // Log at error level so unpaid usage is visible in monitoring/alerts.
@@ -514,11 +443,7 @@ async function completeNonStreamingChat(input: {
durationMs: number durationMs: number
requestId: string requestId: string
userId: string userId: string
sessionId?: string
roundId?: string
appSurface?: AiGenerationAppSurface
requestModel: string requestModel: string
generationModel: string
routeCtxProvider: string routeCtxProvider: string
billing: ChatBilling billing: ChatBilling
billingPolicy: ChatBillingPolicy 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 }) 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). // Debit flux via DB transaction (source of truth).
// The upstream call has already happened (cost incurred), so partial // The upstream call has already happened (cost incurred), so partial
// debit + `fluxUnbilled` is the only sane recovery — same shape as the // debit + `fluxUnbilled` is the only sane recovery — same shape as the
@@ -148,7 +148,6 @@ function createMockLlmRouter(impl?: Partial<LlmRouterService>): LlmRouterService
function createMockProductEventService(): ProductEventService { function createMockProductEventService(): ProductEventService {
return { return {
track: vi.fn(async () => undefined), 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({ const llmRouter = createMockLlmRouter({
route: vi.fn(async (_req, ctx) => { route: vi.fn(async (_req, ctx) => {
if (ctx) { if (ctx) {
@@ -962,88 +961,8 @@ describe('v1CompletionsRoutes', () => {
userId: 'user-1', userId: 'user-1',
}), }),
) )
expect(productEventService.trackGeneration).toHaveBeenCalledWith({ expect(llmTracing.startChatGeneration).toHaveBeenCalledWith(expect.objectContaining({ sessionId: 'conversation-1' }))
userId: 'user-1', expect(productEventService.track).not.toHaveBeenCalled()
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')
}) })
it('should not charge flux when upstream returns error', async () => { it('should not charge flux when upstream returns error', async () => {
@@ -33,7 +33,7 @@ export interface CheckoutOperationInput {
request: Request request: Request
} }
interface PosthogIdentityHeaders { interface OpenpanelIdentityHeaders {
distinctId?: string distinctId?: string
sessionId?: string sessionId?: string
} }
@@ -80,7 +80,7 @@ export function createCheckoutOperation(deps: CheckoutOperationDeps) {
const paymentMethods = await deps.configKV.getOptional('STRIPE_PAYMENT_METHODS') const paymentMethods = await deps.configKV.getOptional('STRIPE_PAYMENT_METHODS')
const paymentMethodOptions = await deps.configKV.getOptional('STRIPE_PAYMENT_METHOD_OPTIONS') ?? {} const paymentMethodOptions = await deps.configKV.getOptional('STRIPE_PAYMENT_METHOD_OPTIONS') ?? {}
const posthogIdentity = readPosthogIdentityHeaders(input.request) const openpanelIdentity = readOpenpanelIdentityHeaders(input.request)
const sessionParams: CheckoutSessionCreateParams = { const sessionParams: CheckoutSessionCreateParams = {
line_items: [{ price: stripePriceId, quantity: 1 }], line_items: [{ price: stripePriceId, quantity: 1 }],
@@ -93,8 +93,8 @@ export function createCheckoutOperation(deps: CheckoutOperationDeps) {
metadata: { metadata: {
userId: input.user.id, userId: input.user.id,
fluxAmount: String(fluxAmount), fluxAmount: String(fluxAmount),
...(posthogIdentity.distinctId && { posthogDistinctId: posthogIdentity.distinctId }), ...(openpanelIdentity.distinctId && { openpanelDeviceId: openpanelIdentity.distinctId }),
...(posthogIdentity.sessionId && { posthogSessionId: posthogIdentity.sessionId }), ...(openpanelIdentity.sessionId && { openpanelSessionId: openpanelIdentity.sessionId }),
}, },
} }
@@ -142,8 +142,8 @@ export function createCheckoutOperation(deps: CheckoutOperationDeps) {
flux_amount: fluxAmount, flux_amount: fluxAmount,
amount_total: session.amount_total, amount_total: session.amount_total,
currency: session.currency, currency: session.currency,
...(posthogIdentity.distinctId && { posthog_distinct_id: posthogIdentity.distinctId }), ...(openpanelIdentity.distinctId && { openpanel_device_id: openpanelIdentity.distinctId }),
...(posthogIdentity.sessionId && { posthog_session_id: posthogIdentity.sessionId }), ...(openpanelIdentity.sessionId && { openpanel_session_id: openpanelIdentity.sessionId }),
}, },
}) })
@@ -151,9 +151,9 @@ export function createCheckoutOperation(deps: CheckoutOperationDeps) {
} }
} }
function readPosthogIdentityHeaders(request: Request): PosthogIdentityHeaders { function readOpenpanelIdentityHeaders(request: Request): OpenpanelIdentityHeaders {
const distinctId = readStripeMetadataHeader(request, 'x-posthog-distinct-id') const distinctId = readStripeMetadataHeader(request, 'x-openpanel-device-id')
const sessionId = readStripeMetadataHeader(request, 'x-posthog-session-id') const sessionId = readStripeMetadataHeader(request, 'x-openpanel-session-id')
return { return {
...(distinctId && { distinctId }), ...(distinctId && { distinctId }),
...(sessionId && { sessionId }), ...(sessionId && { sessionId }),
@@ -89,8 +89,8 @@ export function createWebhookOperation(deps: WebhookOperationDeps) {
const userId = event.data.object.metadata?.userId const userId = event.data.object.metadata?.userId
if (userId) { if (userId) {
const fluxAmount = Number(event.data.object.metadata?.fluxAmount) const fluxAmount = Number(event.data.object.metadata?.fluxAmount)
const posthogDistinctId = event.data.object.metadata?.posthogDistinctId const openpanelDeviceId = event.data.object.metadata?.openpanelDeviceId
const posthogSessionId = event.data.object.metadata?.posthogSessionId const openpanelSessionId = event.data.object.metadata?.openpanelSessionId
void deps.productEventService?.track({ void deps.productEventService?.track({
userId, userId,
feature: 'billing', feature: 'billing',
@@ -104,8 +104,8 @@ export function createWebhookOperation(deps: WebhookOperationDeps) {
flux_amount: Number.isFinite(fluxAmount) ? fluxAmount : null, flux_amount: Number.isFinite(fluxAmount) ? fluxAmount : null,
stripe_checkout_session_id: event.data.object.id, 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, stripe_customer_id: typeof event.data.object.customer === 'string' ? event.data.object.customer : event.data.object.customer?.id ?? null,
...(posthogDistinctId && { posthog_distinct_id: posthogDistinctId }), ...(openpanelDeviceId && { openpanel_device_id: openpanelDeviceId }),
...(posthogSessionId && { posthog_session_id: posthogSessionId }), ...(openpanelSessionId && { openpanel_session_id: openpanelSessionId }),
}, },
}) })
} }
@@ -228,6 +228,9 @@ async function handleCheckoutSessionCompleted(
applied: result.applied, applied: result.applied,
balanceAfter: result.balanceAfter, balanceAfter: result.balanceAfter,
}).log('Processed flux credit for one-time payment') }).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 } return { processed: true }
+16 -12
View File
@@ -296,7 +296,7 @@ describe('stripeRoutes', () => {
expect(res.status).toBe(503) 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 => ({ const createSession = vi.fn(async input => ({
id: 'cs_1', id: 'cs_1',
url: 'https://checkout.stripe.com/cs_1', url: 'https://checkout.stripe.com/cs_1',
@@ -343,8 +343,8 @@ describe('stripeRoutes', () => {
body: { stripePriceId: 'price_test_500' }, body: { stripePriceId: 'price_test_500' },
request: new Request('http://localhost/api/v1/stripe/checkout', { request: new Request('http://localhost/api/v1/stripe/checkout', {
headers: { headers: {
'x-posthog-distinct-id': 'anon-browser-1', 'x-openpanel-device-id': 'anon-browser-1',
'x-posthog-session-id': 'ph-session-1', 'x-openpanel-session-id': 'ph-session-1',
}, },
}), }),
}) })
@@ -353,16 +353,16 @@ describe('stripeRoutes', () => {
metadata: { metadata: {
userId: 'user-1', userId: 'user-1',
fluxAmount: '500', fluxAmount: '500',
posthogDistinctId: 'anon-browser-1', openpanelDeviceId: 'anon-browser-1',
posthogSessionId: 'ph-session-1', openpanelSessionId: 'ph-session-1',
}, },
})) }))
expect(productEventService.track).toHaveBeenCalledWith(expect.objectContaining({ expect(productEventService.track).toHaveBeenCalledWith(expect.objectContaining({
userId: 'user-1', userId: 'user-1',
action: 'checkout_started', action: 'checkout_started',
metadata: expect.objectContaining({ metadata: expect.objectContaining({
posthog_distinct_id: 'anon-browser-1', openpanel_device_id: 'anon-browser-1',
posthog_session_id: 'ph-session-1', openpanel_session_id: 'ph-session-1',
}), }),
})) }))
}) })
@@ -535,7 +535,7 @@ describe('stripeRoutes', () => {
expect(res.status).toBe(503) 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 = { const checkoutEvent = {
id: 'evt_checkout_completed', id: 'evt_checkout_completed',
type: 'checkout.session.completed', type: 'checkout.session.completed',
@@ -556,8 +556,8 @@ describe('stripeRoutes', () => {
metadata: { metadata: {
userId: 'user-1', userId: 'user-1',
fluxAmount: '500', fluxAmount: '500',
posthogDistinctId: 'anon-browser-1', openpanelDeviceId: 'anon-browser-1',
posthogSessionId: 'ph-session-1', openpanelSessionId: 'ph-session-1',
}, },
expires_at: null, expires_at: null,
}, },
@@ -599,10 +599,14 @@ describe('stripeRoutes', () => {
flux_amount: 500, flux_amount: 500,
stripe_checkout_session_id: 'cs_1', stripe_checkout_session_id: 'cs_1',
stripe_customer_id: 'cus_1', stripe_customer_id: 'cus_1',
posthog_distinct_id: 'anon-browser-1', openpanel_device_id: 'anon-browser-1',
posthog_session_id: 'ph-session-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 () => { it('processes subscription lifecycle webhooks without product events', async () => {
@@ -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<void>(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<void>((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<void>(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<void>((resolve, reject) => server.close(error => error ? reject(error) : resolve()))
}
})
})
@@ -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<string, unknown>
deviceId?: string
}
/** External delivery boundary for confirmed product facts. */
export interface ProductAnalyticsSink {
capture: (input: ProductCaptureInput) => Promise<void>
}
/**
* 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})`)
},
}
}
@@ -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<string, unknown>
/** 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<void>
/** Flush and close the underlying client. Call on server shutdown. */
shutdown: () => Promise<void>
}
/**
* 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<void> {
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<void> {
await client.shutdown()
},
}
}
@@ -1,181 +1,67 @@
import type { ProductCaptureInput } from '../adapters/openpanel'
import { describe, expect, it, vi } from 'vitest' import { describe, expect, it, vi } from 'vitest'
import { createProductEventService } from './product-events' import { createProductEventService } from './product-events'
describe('productEventService', () => { describe('productEventService', () => {
it('captures only the server-side funnel facts shared with the Go service', async () => { it('routes confirmed product facts to OpenPanel with stable AIRI user ids', async () => {
const capture = vi.fn(async () => {}) const capture = vi.fn<(input: ProductCaptureInput) => Promise<void>>().mockResolvedValue(undefined)
const service = createProductEventService({ capture, shutdown: vi.fn(async () => {}) }) const service = createProductEventService({ capture })
await service.track({ userId: 'user-1', feature: 'auth', action: 'user_signed_up', status: 'succeeded' })
await service.track({ await service.track({ userId: 'user-2', feature: 'billing', action: 'checkout_started', status: 'succeeded' })
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' },
})
expect(capture).toHaveBeenNthCalledWith(1, { expect(capture).toHaveBeenNthCalledWith(1, {
distinctId: 'user-1', userId: 'user-1',
event: 'signup_completed', event: 'signup_completed',
properties: { properties: { app_surface: 'server', airi_user_id: 'user-1', feature: 'auth', status: 'succeeded' },
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',
},
}) })
expect(capture).toHaveBeenNthCalledWith(2, expect.objectContaining({ userId: 'user-2', event: 'checkout_created' }))
}) })
it('merges a Stripe conversion with its browser PostHog person', async () => { it('passes the checkout device id and source event id without an extra identify event', async () => {
const capture = vi.fn(async () => {}) const capture = vi.fn<(input: ProductCaptureInput) => Promise<void>>().mockResolvedValue(undefined)
const service = createProductEventService({ capture, shutdown: vi.fn(async () => {}) }) const service = createProductEventService({ capture })
await service.track({ await service.track({
userId: 'user-1', userId: 'user-1',
feature: 'billing', feature: 'billing',
action: 'payment_completed', action: 'payment_completed',
status: 'succeeded', status: 'succeeded',
eventId: 'cs_123', eventId: 'cs_123',
metadata: { metadata: { openpanel_device_id: 'browser-1', amount_total: 990, currency: 'usd' },
posthog_distinct_id: 'anon-browser-1',
posthog_session_id: 'ph-session-1',
},
}) })
expect(capture).toHaveBeenCalledTimes(1)
expect(capture).toHaveBeenNthCalledWith(1, { expect(capture).toHaveBeenCalledWith({
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<string, unknown>
},
]>
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({
userId: 'user-1', userId: 'user-1',
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', feature: 'billing',
action: 'payment_completed',
status: 'succeeded', status: 'succeeded',
metadata: { $insert_id: 'spoofed' }, },
})).resolves.toBeUndefined() })
})
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() expect(capture).not.toHaveBeenCalled()
}) })
it('still captures the funnel event when identity merging fails', async () => { it('does not fail the business path when OpenPanel is unavailable', 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 () => {
const capture = vi.fn(async () => { const capture = vi.fn(async () => {
throw new Error('posthog exploded') throw new Error('unavailable')
}) })
const service = createProductEventService({ capture, shutdown: vi.fn(async () => {}) }) const service = createProductEventService({ capture })
await expect(service.track({ userId: 'user-1', feature: 'billing', action: 'payment_completed', status: 'succeeded' })).resolves.toBeUndefined()
await expect(service.track({
userId: 'user-1',
feature: 'billing',
action: 'payment_completed',
status: 'succeeded',
})).resolves.toBeUndefined()
}) })
}) })
@@ -1,13 +1,14 @@
import type { PosthogSink } from '../adapters/posthog' import type { ProductAnalyticsSink } from '../adapters/openpanel'
import { createHash } from 'node:crypto'
import { useLogger } from '@guiiai/logg' import { useLogger } from '@guiiai/logg'
const logger = useLogger('product-events') const logger = useLogger('product-events')
const RESERVED_POSTHOG_METADATA_KEYS = new Set([ const RESERVED_PRODUCT_METADATA_KEYS = new Set([
'$insert_id', 'event_id',
'__deviceId',
'__identify',
'profileId',
'$session_id', '$session_id',
'airi_user_id', 'airi_user_id',
'app_surface', 'app_surface',
@@ -27,9 +28,9 @@ export type ProductAction
| 'checkout_started' | 'checkout_started'
| 'payment_completed' | 'payment_completed'
/** Product funnel fact forwarded to PostHog from the server. */ /** Product funnel fact forwarded to OpenPanel from the server. */
export interface ProductEventInput { 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 userId: string
/** Bounded product area used for product dashboards and funnels. */ /** Bounded product area used for product dashboards and funnels. */
feature: ProductFeature feature: ProductFeature
@@ -41,58 +42,19 @@ export interface ProductEventInput {
source?: string source?: string
/** Optional primitive metadata for product analysis. Avoid PII and raw prompts. */ /** Optional primitive metadata for product analysis. Avoid PII and raw prompts. */
metadata?: ProductEventMetadata 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 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. * and TTS telemetry stays in operational systems and does not enter this path.
* *
* `user_signed_up` maps to `signup_completed` because the identified server * `user_signed_up` maps to `signup_completed` because the identified server
* hook is the canonical registration fact for every signup method. Anonymous * hook is the canonical registration fact for every signup method. Anonymous
* auth UI progress uses `signup_form_completed` and never reuses this name. * auth UI progress uses `signup_form_completed` and never reuses this name.
*/ */
const POSTHOG_FORWARDED_ACTIONS: Partial<Record<ProductAction, string>> = { const FORWARDED_ACTIONS: Partial<Record<ProductAction, string>> = {
user_signed_up: 'signup_completed', user_signed_up: 'signup_completed',
checkout_started: 'checkout_created', checkout_started: 'checkout_created',
payment_completed: 'payment_completed', 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 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 { 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: * Use when:
* - A server has an authenticated user id and confirms a funnel fact. * - A server has an authenticated user id and confirms a funnel fact.
@@ -128,102 +82,38 @@ function hasReservedMetadataKey(metadata: ProductEventMetadata | undefined): boo
* Returns: * Returns:
* - A best-effort event writer. Capture errors never change the business flow. * - 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 { 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<void> { async track(input: ProductEventInput): Promise<void> {
const forwardedEvent = POSTHOG_FORWARDED_ACTIONS[input.action] const forwardedEvent = FORWARDED_ACTIONS[input.action]
if (!posthog || !forwardedEvent) if (!sink || !forwardedEvent)
return return
if (hasReservedMetadataKey(input.metadata)) { 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 return
} }
const posthogDistinctId = stringMetadata(input, 'posthog_distinct_id') const deviceId = stringMetadata(input, 'openpanel_device_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')
}
}
try { try {
await posthog.capture({ await sink.capture({
distinctId: input.userId, userId: input.userId,
...(deviceId && { deviceId }),
event: forwardedEvent, event: forwardedEvent,
properties: { properties: {
...input.metadata, ...input.metadata,
...(input.eventId && { $insert_id: input.eventId }), ...(input.eventId && { event_id: input.eventId }),
app_surface: 'server', app_surface: 'server',
airi_user_id: input.userId, airi_user_id: input.userId,
...(posthogDistinctId && { posthog_distinct_id: posthogDistinctId }),
...(posthogSessionId && { $session_id: posthogSessionId }),
feature: input.feature, feature: input.feature,
status: input.status, status: input.status,
...(input.source && { source: input.source }), ...(input.source && { source: input.source }),
}, },
...(input.eventId && { uuid: posthogEventUuid(forwardedEvent, input.eventId) }),
}) })
} }
catch (err) { 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')
} }
}, },
} }
+1 -2
View File
@@ -4,8 +4,7 @@ interface ImportMetaEnv {
readonly VITE_DISTRIBUTION?: 'direct' | 'steam' readonly VITE_DISTRIBUTION?: 'direct' | 'steam'
readonly VITE_DISABLE_FLUX_PURCHASE?: string readonly VITE_DISABLE_FLUX_PURCHASE?: string
readonly VITE_DISABLE_CUSTOM_PROVIDERS?: string readonly VITE_DISABLE_CUSTOM_PROVIDERS?: string
readonly VITE_ENABLE_POSTHOG?: string readonly VITE_ENABLE_ANALYTICS?: string
readonly VITE_POSTHOG_PROJECT_KEY?: string
} }
interface ImportMeta { interface ImportMeta {