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