refactor(analytics): replace PostHog with OpenPanel (#2480)
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:",
|
||||||
|
|||||||
@@ -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()) {
|
||||||
|
|||||||
@@ -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:",
|
||||||
|
|||||||
@@ -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,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
|
||||||
|
|||||||
@@ -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:",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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:",
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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' })
|
||||||
|
}
|
||||||
@@ -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' })
|
|
||||||
}
|
|
||||||
@@ -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
@@ -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:",
|
||||||
|
|||||||
@@ -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:",
|
||||||
|
|||||||
@@ -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:",
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
Generated
+85
-111
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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:",
|
||||||
|
|||||||
@@ -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
@@ -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', {
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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')
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+1
-2
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user