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

This commit is contained in:
RainbowBird
2026-09-09 08:26:18 +00:00
committed by GitHub
parent 5e78b64e35
commit 66f0d45028
68 changed files with 802 additions and 1252 deletions
@@ -49,16 +49,8 @@ jobs:
- name: Build ui-server-auth
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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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:
+1 -1
View File
@@ -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:
-1
View File
@@ -85,7 +85,6 @@
"nprogress": "catalog:",
"onnxruntime-web": "catalog:",
"pinia": "catalog:",
"posthog-js": "catalog:",
"rehype-stringify": "catalog:",
"reka-ui": "catalog:",
"remark-parse": "catalog:",
+2 -2
View File
@@ -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()) {
-1
View File
@@ -120,7 +120,6 @@
"onnxruntime-web": "catalog:",
"pinia": "catalog:",
"popmotion": "catalog:",
"posthog-js": "catalog:",
"rehype-stringify": "catalog:",
"reka-ui": "catalog:",
"remark-parse": "catalog:",
+2 -2
View File
@@ -42,8 +42,8 @@ import '@fontsource/m-plus-rounded-1c/index.css'
import '@fontsource-variable/nunito/index.css'
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 -2
View File
@@ -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
-1
View File
@@ -83,7 +83,6 @@
"nprogress": "catalog:",
"onnxruntime-web": "catalog:",
"pinia": "catalog:",
"posthog-js": "catalog:",
"rehype-stringify": "catalog:",
"reka-ui": "catalog:",
"remark-parse": "catalog:",
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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;
+1 -1
View File
@@ -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:",
+3 -3
View File
@@ -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.
+1 -1
View File
@@ -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
+83
View File
@@ -0,0 +1,83 @@
# OpenPanel product analytics
AIRI sends product events to a self-hosted OpenPanel project. Langfuse handles AI tracing through the existing server integration.
Use this setup for product events, user profiles, and conversion funnels. It does not migrate historical events, HogQL queries, or saved PostHog dashboards.
## Client configuration
Use `VITE_ENABLE_ANALYTICS` as the build switch for app analytics. Existing user opt-out behavior remains active. The production docs site records page views separately.
Client builds use the public API URL and write client id from `packages/stage-shared/src/analytics/openpanel.ts`.
The stage apps and auth UI share this configuration. CI variables and Docker build arguments are not required for these values.
Never put a client secret in a Vite variable. Allow only the intended application origins on the public OpenPanel client.
## Server configuration
Set these variables through the API deployment platform:
- `OPENPANEL_API_URL`
- `OPENPANEL_CLIENT_ID`
- `OPENPANEL_CLIENT_SECRET`
Use a separate server write client in the same project. Keep its secret in the deployment platform's secret store.
All three absent disables server product forwarding. A partial configuration fails startup.
## Event behavior
Business event names remain unchanged. OpenPanel uses `screen_view` for automatic page views. Automatic outbound-link capture, attribute capture, and replay remain disabled.
Stage clients keep a device id in session storage. A reload in the same tab keeps this id. Logout or a consent change rotates it. Anonymous visitor counts therefore do not have the same semantics as PostHog's persistent browser identity.
Logged-in events use the stable AIRI user id. Checkout sends the device id to the API, which stores it in Stripe metadata. The webhook supplies that device id to OpenPanel.
Query strings and fragments are removed from automatic page URLs and referrers. This prevents OAuth codes from entering those fields.
Per-generation analytics events are removed. Completed `message_round` events retain model, token usage, duration, and round identifiers.
`message_sent` retains provider selection. `message_round_failed` retains the failure stage and error code.
Official model tracing, operational metrics, request logs, and billing remain in their existing services.
No client sends prompts or model responses to OpenPanel.
## Conversion delivery
The billing transaction suppresses replayed one-time payment conversions. Only a transaction that applies the Flux credit emits the conversion.
Server delivery makes one request with a five-second timeout. OpenPanel does not provide PostHog's stable UUID deduplication contract. An ambiguous request is not retried.
Delivery remains best effort. A network failure or process exit can lose a conversion. Use billing records for financial totals. The `event_id` property supports reconciliation.
## Deployment
The deployment uses the official `self-hosting` source at commit `cd24bb838301df1a9087e2e132b344e5b8a99963`.
The stack includes Caddy, PostgreSQL, Redis, ClickHouse, API, Dashboard, and one worker. Persistent volumes hold all database state.
Deployment adjustments:
- Generate the PostgreSQL password and cookie secret on the host.
- Require successful migrations before the API starts.
- Create the `openpanel` ClickHouse database before the first API migration.
- Allow 120 seconds for API, Dashboard, and worker startup checks.
- Keep database ports private.
- Keep the proxy on loopback until the domain and TLS configuration are complete.
- Pin the deployed application image digests before later upgrades.
AWS Systems Manager manages the host. Public SSH is closed. Automatic snapshot management requires a separate approved AWS service role.
## Release checks
Before merging and releasing:
1. Confirm the public domain, DNS, and HTTPS certificate.
2. Confirm the administrator, project, public client, and server client.
3. Configure allowed origins and server secrets.
4. Send a marked browser event and a server event to the deployed API.
5. Confirm both events and their user association in OpenPanel.
6. Verify checkout attribution with a test-mode Stripe payment and a replayed webhook.
7. Recreate the required dashboards and reporting queries.
8. Update the published privacy notice for the final deployment.
9. Confirm backup and restore procedures.
Retain PostHog history during migration. Reverting this PR restores the old product event path, but it cannot backfill events captured only in OpenPanel.
+23
View File
@@ -0,0 +1,23 @@
import { OpenPanel } from '@openpanel/web'
import { OPENPANEL_CONFIG } from '@proj-airi/stage-shared/analytics/openpanel'
if (!import.meta.env.DEV) {
const panel = new OpenPanel({
...OPENPANEL_CONFIG,
trackScreenViews: true,
trackOutgoingLinks: false,
trackAttributes: false,
filter(payload) {
// Page metadata must not include query values or URL fragments.
if (payload.type === 'track' && payload.payload.properties) {
for (const key of ['__path', '__referrer']) {
const value = payload.payload.properties[key]
if (typeof value === 'string')
payload.payload.properties[key] = value.split(/[?#]/, 1)[0]
}
}
return true
},
})
panel.setGlobalProperties({ app_surface: 'docs' })
}
-12
View File
@@ -1,12 +0,0 @@
import posthog from 'posthog-js'
import { DEFAULT_POSTHOG_CONFIG, POSTHOG_PROJECT_KEY } from '@proj-airi/stage-shared/analytics/posthog'
if (!import.meta.env.DEV) {
posthog.init(POSTHOG_PROJECT_KEY, {
...DEFAULT_POSTHOG_CONFIG,
})
// Tag docs-site traffic so it can be told apart from the app surfaces
// inside the shared project.
posthog.register({ app_surface: 'docs' })
}
+1 -1
View File
@@ -26,7 +26,7 @@ export default {
Layout,
enhanceApp({ app, siteData }) {
if (!import.meta.env.SSR && import.meta.env.PROD) {
import('../modules/posthog')
import('../modules/openpanel')
}
const i18n = createI18n({
+1 -1
View File
@@ -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:",
-1
View File
@@ -78,7 +78,6 @@
"oxc-minify": "catalog:",
"oxlint": "catalog:",
"playwright": "catalog:",
"posthog-js": "catalog:",
"publint": "catalog:",
"rollup": "catalog:",
"simple-git-hooks": "catalog:",
-1
View File
@@ -51,7 +51,6 @@
"nanoid": "catalog:",
"node-vibrant": "catalog:",
"pinia": "catalog:",
"posthog-js": "catalog:",
"reka-ui": "catalog:",
"unspeech": "catalog:xsai",
"vue": "catalog:",
-1
View File
@@ -47,7 +47,6 @@
"nanoid": "catalog:",
"node-vibrant": "catalog:",
"pinia": "catalog:",
"posthog-js": "catalog:",
"reka-ui": "catalog:",
"splitpanes": "catalog:",
"unspeech": "catalog:xsai",
@@ -282,10 +282,10 @@ onMounted(async () => {
await creditsRefresh.catch(() => undefined)
// PostHog funnel step 1: pricing surface view. Today this is an in-app
// OpenPanel funnel step 1: pricing surface view. Today this is an in-app
// settings page (already-authenticated users); when we add a public
// pricing landing page the entry-surface label changes but the event stays the
// same, so the funnel definition in PostHog doesn't need re-wiring.
// same, so the funnel definition in OpenPanel doesn't need re-wiring.
if (!fluxPurchaseDisabled) {
trackPaywallSeen({
entry_surface: 'settings_flux',
@@ -308,7 +308,7 @@ async function handleBuy(stripePriceId: string) {
loadingPriceId.value = stripePriceId
checkoutReturnMessageActive.value = false
message.value = null
// PostHog funnel step 2: user picked a plan. price_minor_unit lives on
// OpenPanel funnel step 2: user picked a plan. price_minor_unit lives on
// the Stripe webhook (server-side `payment_completed`); we deliberately
// don't send a formatted-string price from the SPA so funnels don't get
// poisoned by currency-formatting drift.
@@ -330,9 +330,8 @@ async function handleBuy(stripePriceId: string) {
}
const data = await res.json()
if (data.url) {
// PostHog funnel step 3: about to redirect to Stripe. Capture before
// the page nav so the event is sent (PostHog's beforeunload handler
// would otherwise race the navigation).
// Start capture before redirecting to Stripe so fetch keepalive can
// finish delivery after the page unloads.
trackCheckoutStarted(stripePriceId, {
currency: selectedCurrency.value,
entry_surface: 'settings_flux',
+1 -2
View File
@@ -17,7 +17,7 @@
"exports": {
".": "./src/index.ts",
"./auth": "./src/auth/index.ts",
"./analytics/posthog": "./src/analytics/posthog.ts",
"./analytics/openpanel": "./src/analytics/openpanel.ts",
"./beat-sync": "./src/beat-sync/index.ts",
"./global-shortcut": "./src/global-shortcut/index.ts",
"./godot-stage": "./src/godot-stage/index.ts",
@@ -37,7 +37,6 @@
"@vueuse/core": "catalog:",
"gpuu": "catalog:",
"pinia": "catalog:",
"posthog-js": "catalog:",
"valibot": "catalog:",
"vue": "catalog:"
},
@@ -0,0 +1,5 @@
/** Public write configuration shared by AIRI client surfaces. Never add a client secret here. */
export const OPENPANEL_CONFIG = {
apiUrl: 'https://analytics.airi.build/api',
clientId: 'f930f223-4a46-4064-b919-1a5465dbc029',
} as const
@@ -1,25 +0,0 @@
import type { PostHogConfig } from 'posthog-js'
function isEnvFlagEnabled(value: string | undefined): boolean {
if (value == null)
return false
return /^(?:1|true|t|yes|y|on)$/i.test(value.trim())
}
/** Whether client analytics is enabled for the current Vite build. */
export const POSTHOG_ENABLED = isEnvFlagEnabled(import.meta.env.VITE_ENABLE_POSTHOG)
/** The shared PostHog project key used by every AIRI client surface. */
export const POSTHOG_PROJECT_KEY
= import.meta.env.VITE_POSTHOG_PROJECT_KEY
?? 'phc_pzjziJjrVZpa9SqnQqq0QEKvkmuCPH7GDTA6TbRTEf9' // cspell:disable-line
/** Shared PostHog defaults for AIRI single-page applications. */
export const DEFAULT_POSTHOG_CONFIG = {
api_host: 'https://t.airi.build',
// This preset captures page views on history changes. PostHog then also
// captures page leaves, which keeps route-level dwell time measurable.
defaults: '2025-05-24',
person_profiles: 'identified_only',
} as const satisfies Partial<PostHogConfig>
+1 -1
View File
@@ -75,6 +75,7 @@
"@huggingface/transformers": "catalog:",
"@moeru/eventa": "catalog:",
"@moeru/std": "catalog:",
"@openpanel/web": "catalog:",
"@opentelemetry/api": "catalog:",
"@opentelemetry/core": "catalog:",
"@opentelemetry/sdk-trace-base": "catalog:",
@@ -150,7 +151,6 @@
"ofetch": "catalog:",
"pinia": "catalog:",
"pinia-plugin-synced": "catalog:",
"posthog-js": "catalog:",
"rehype-katex": "catalog:",
"rehype-parse": "catalog:",
"rehype-stringify": "catalog:",
@@ -9,7 +9,7 @@ const analyticsMocks = vi.hoisted(() => ({
isStageTamagotchiMock: vi.fn(() => false),
isAnalyticsAvailableInBuildMock: vi.fn(() => true),
recordFirstMessageMock: vi.fn(() => true),
posthogCaptureMock: vi.fn(),
captureMock: vi.fn(),
}))
vi.mock('@proj-airi/stage-shared', () => ({
@@ -29,14 +29,14 @@ vi.mock('vue-i18n', () => ({
}))
vi.mock('../libs/product-signals', () => ({
captureAnalyticsEvent: analyticsMocks.posthogCaptureMock,
captureAnalyticsEvent: analyticsMocks.captureMock,
enableAnalytics: analyticsMocks.ensureAnalyticsInitializedMock,
getAnalytics: () => ({
emit: (event: { name: string }, payload: object, options?: object) => {
if (options)
return analyticsMocks.posthogCaptureMock(event.name, payload, options)
return analyticsMocks.captureMock(event.name, payload, options)
return analyticsMocks.posthogCaptureMock(event.name, payload)
return analyticsMocks.captureMock(event.name, payload)
},
recordFirstMessage: analyticsMocks.recordFirstMessageMock,
}),
@@ -58,7 +58,7 @@ vi.mock('../stores/settings/general', () => ({
describe('useAnalytics conversation product events', () => {
beforeEach(() => {
analyticsMocks.posthogCaptureMock.mockClear()
analyticsMocks.captureMock.mockClear()
analyticsMocks.recordFirstMessageMock.mockClear()
analyticsMocks.ensureAnalyticsInitializedMock.mockClear()
analyticsMocks.isStageCapacitorMock.mockReset()
@@ -75,7 +75,7 @@ describe('useAnalytics conversation product events', () => {
reason: 'manual-chat',
})
expect(analyticsMocks.posthogCaptureMock).toHaveBeenCalledWith('tts_stop_clicked', {
expect(analyticsMocks.captureMock).toHaveBeenCalledWith('tts_stop_clicked', {
app_surface: 'web',
reason: 'manual-chat',
})
@@ -89,82 +89,13 @@ describe('useAnalytics conversation product events', () => {
was_speaking: true,
})
expect(analyticsMocks.posthogCaptureMock).toHaveBeenCalledWith('speech_mute_toggled', {
expect(analyticsMocks.captureMock).toHaveBeenCalledWith('speech_mute_toggled', {
app_surface: 'web',
muted: true,
was_speaking: true,
})
})
it('captures custom-provider token usage without prompt or response content', () => {
const analytics = useAnalytics()
analytics.trackAiGeneration({
conversation_id: 'session-1',
round_id: 'round-1',
provider_type: 'custom',
provider_id: 'openai-compatible',
model_id: 'custom-model',
usage_source: 'reported',
input_tokens: 12,
output_tokens: 8,
})
expect(analyticsMocks.posthogCaptureMock).toHaveBeenCalledWith('$ai_generation', {
$ai_trace_id: 'session-1',
$ai_session_id: 'session-1',
$ai_span_id: 'round-1',
$ai_model: 'custom-model',
$ai_provider: 'openai-compatible',
$ai_input_tokens: 12,
$ai_output_tokens: 8,
$ai_total_tokens: 20,
$insert_id: 'ai-generation:round-1',
app_surface: 'web',
capture_surface: 'client',
conversation_id: 'session-1',
conversation_id_source: 'client_runtime',
round_id: 'round-1',
provider_type: 'custom',
usage_source: 'reported',
token_usage_available: true,
cost_usd_source: 'unavailable',
cost_usd_known: false,
})
})
it('records unavailable custom-provider usage without inventing token or cost fields', () => {
const analytics = useAnalytics()
analytics.trackAiGeneration({
conversation_id: 'session-1',
round_id: 'round-2',
provider_type: 'custom',
provider_id: 'ollama',
model_id: 'local-model',
usage_source: 'unavailable',
})
expect(analyticsMocks.posthogCaptureMock).toHaveBeenCalledWith('$ai_generation', {
$ai_trace_id: 'session-1',
$ai_session_id: 'session-1',
$ai_span_id: 'round-2',
$ai_model: 'local-model',
$ai_provider: 'ollama',
$insert_id: 'ai-generation:round-2',
app_surface: 'web',
capture_surface: 'client',
conversation_id: 'session-1',
conversation_id_source: 'client_runtime',
round_id: 'round-2',
provider_type: 'custom',
usage_source: 'unavailable',
token_usage_available: false,
cost_usd_source: 'unavailable',
cost_usd_known: false,
})
})
it('infers the mobile surface for capacitor conversation actions', () => {
analyticsMocks.isStageCapacitorMock.mockReturnValue(true)
const analytics = useAnalytics()
@@ -175,7 +106,7 @@ describe('useAnalytics conversation product events', () => {
cloud_synced: true,
})
expect(analyticsMocks.posthogCaptureMock).toHaveBeenCalledWith('chat_session_selected', {
expect(analyticsMocks.captureMock).toHaveBeenCalledWith('chat_session_selected', {
app_surface: 'mobile',
source: 'sessions_drawer',
message_count: 4,
@@ -199,17 +130,17 @@ describe('useAnalytics conversation product events', () => {
source: 'history',
})
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(1, 'chat_message_deleted', {
expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(1, 'chat_message_deleted', {
app_surface: 'electron',
source: 'history',
message_role: 'assistant',
})
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(2, 'chat_messages_cleared', {
expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(2, 'chat_messages_cleared', {
app_surface: 'electron',
source: 'chat_controls',
message_count: 3,
})
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(3, 'chat_message_retried', {
expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(3, 'chat_message_retried', {
app_surface: 'electron',
source: 'history',
})
@@ -218,7 +149,7 @@ describe('useAnalytics conversation product events', () => {
/**
* @example
* analytics.trackVoiceSelected({ tts_provider_id: 'official-provider', tts_model_id: 'stepfun/tts', voice_id: 'voice-1', voice_type: 'official_selected', source: 'settings' })
* expect(posthog.capture).toHaveBeenCalledWith('voice_selected', expect.objectContaining({ voice_id: 'voice-1' }))
* expect(captureAnalyticsEvent).toHaveBeenCalledWith('voice_selected', expect.objectContaining({ voice_id: 'voice-1' }))
*/
it('emits TTS voice selection events without losing provider context', () => {
const analytics = useAnalytics()
@@ -250,7 +181,7 @@ describe('useAnalytics conversation product events', () => {
source: 'settings',
})
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(1, 'tts_provider_selected', {
expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(1, 'tts_provider_selected', {
app_surface: 'web',
tts_provider_id: 'official-provider',
tts_model_id: 'stepfun/tts',
@@ -258,7 +189,7 @@ describe('useAnalytics conversation product events', () => {
trigger_method: 'selection',
trigger_type: 'user_action',
})
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(2, 'voice_selected', {
expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(2, 'voice_selected', {
app_surface: 'web',
tts_provider_id: 'official-provider',
tts_model_id: 'stepfun/tts',
@@ -266,7 +197,7 @@ describe('useAnalytics conversation product events', () => {
voice_type: 'official_selected',
source: 'settings',
})
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(3, 'voice_preview_played', {
expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(3, 'voice_preview_played', {
app_surface: 'web',
tts_provider_id: 'official-provider',
tts_model_id: 'stepfun/tts',
@@ -274,7 +205,7 @@ describe('useAnalytics conversation product events', () => {
voice_type: 'official_selected',
source: 'manual_preview',
})
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(4, 'voice_pack_bound', {
expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(4, 'voice_pack_bound', {
app_surface: 'web',
tts_provider_id: 'official-provider',
tts_model_id: 'stepfun/tts',
@@ -287,7 +218,7 @@ describe('useAnalytics conversation product events', () => {
/**
* @example
* analytics.trackOfficialTtsExposed({ source: 'post_first_chat', tts_provider_id: 'official-provider-speech', tts_model_id: 'stepfun/tts' })
* expect(posthog.capture).toHaveBeenCalledWith('official_tts_exposed', expect.objectContaining({ source: 'post_first_chat' }))
* expect(captureAnalyticsEvent).toHaveBeenCalledWith('official_tts_exposed', expect.objectContaining({ source: 'post_first_chat' }))
*/
it('emits official TTS activation funnel events', () => {
const analytics = useAnalytics()
@@ -312,13 +243,13 @@ describe('useAnalytics conversation product events', () => {
source: 'manual_preview',
duration_ms: 320,
})
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(1, 'official_tts_exposed', {
expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(1, 'official_tts_exposed', {
app_surface: 'web',
tts_provider_id: 'official-provider-speech',
tts_model_id: 'stepfun/tts',
source: 'post_first_chat',
})
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(2, 'official_tts_preview_started', {
expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(2, 'official_tts_preview_started', {
app_surface: 'web',
tts_provider_id: 'official-provider-speech',
tts_model_id: 'stepfun/tts',
@@ -326,7 +257,7 @@ describe('useAnalytics conversation product events', () => {
voice_type: 'official_selected',
source: 'manual_preview',
})
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(3, 'official_tts_preview_succeeded', {
expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(3, 'official_tts_preview_succeeded', {
app_surface: 'web',
tts_provider_id: 'official-provider-speech',
tts_model_id: 'stepfun/tts',
@@ -346,7 +277,7 @@ describe('useAnalytics conversation product events', () => {
flux_balance_bucket: '1_100',
})
expect(analyticsMocks.posthogCaptureMock).toHaveBeenCalledWith('paywall_seen', {
expect(analyticsMocks.captureMock).toHaveBeenCalledWith('paywall_seen', {
app_surface: 'web',
entry_surface: 'settings_flux',
reason: 'manual_topup',
@@ -367,16 +298,16 @@ describe('useAnalytics conversation product events', () => {
entry_surface: 'settings_flux',
})
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(1, 'pricing_page_viewed', {
expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(1, 'pricing_page_viewed', {
entry_surface: 'settings_flux',
plan_period: 'one_time',
})
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(2, 'plan_selected', {
expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(2, 'plan_selected', {
currency: 'USD',
entry_surface: 'settings_flux',
plan_id: 'price-1',
})
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(3, 'checkout_started', {
expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(3, 'checkout_started', {
currency: 'USD',
entry_surface: 'settings_flux',
plan_id: 'price-1',
@@ -386,7 +317,7 @@ describe('useAnalytics conversation product events', () => {
/**
* @example
* analytics.trackMicrophonePermissionDenied({ stt_provider_id: 'browser-web-speech-api' })
* expect(posthog.capture).toHaveBeenCalledWith('microphone_permission_denied', expect.objectContaining({ app_surface: 'web' }))
* expect(captureAnalyticsEvent).toHaveBeenCalledWith('microphone_permission_denied', expect.objectContaining({ app_surface: 'web' }))
*/
it('emits one voice action and only user-relevant outcome events', () => {
const analytics = useAnalytics()
@@ -403,33 +334,33 @@ describe('useAnalytics conversation product events', () => {
duration_ms: 420,
})
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(1, 'voice_input_started', {
expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(1, 'voice_input_started', {
app_surface: 'web',
stt_provider_id: 'browser-web-speech-api',
trigger_method: 'voice',
trigger_type: 'user_action',
})
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(2, 'microphone_permission_denied', {
expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(2, 'microphone_permission_denied', {
app_surface: 'web',
stt_provider_id: 'browser-web-speech-api',
error_code: 'permission_denied',
trigger_method: 'voice',
trigger_type: 'user_flow_result',
})
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(3, 'voice_input_cancelled', {
expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(3, 'voice_input_cancelled', {
app_surface: 'web',
stt_provider_id: 'browser-web-speech-api',
duration_ms: 420,
trigger_method: 'voice',
trigger_type: 'user_flow_result',
})
expect(analyticsMocks.posthogCaptureMock).toHaveBeenCalledTimes(3)
expect(analyticsMocks.captureMock).toHaveBeenCalledTimes(3)
})
/**
* @example
* analytics.trackProviderConnectionTestCompleted({ provider_id: 'openai-compatible', provider_mode: 'custom', success: false, error_code: 'validation_failed', duration_ms: 32 })
* expect(posthog.capture).toHaveBeenCalledWith('provider_connection_test_completed', expect.objectContaining({ error_code: 'validation_failed' }))
* expect(captureAnalyticsEvent).toHaveBeenCalledWith('provider_connection_test_completed', expect.objectContaining({ error_code: 'validation_failed' }))
*/
it('emits one manual provider test action and one result', () => {
const analytics = useAnalytics()
@@ -445,14 +376,14 @@ describe('useAnalytics conversation product events', () => {
success: true,
})
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(1, 'provider_connection_test_started', {
expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(1, 'provider_connection_test_started', {
app_surface: 'web',
provider_id: 'official-provider',
provider_mode: 'official',
trigger_method: 'button',
trigger_type: 'user_action',
})
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(2, 'provider_connection_test_completed', {
expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(2, 'provider_connection_test_completed', {
app_surface: 'web',
provider_id: 'official-provider',
provider_mode: 'official',
@@ -461,7 +392,7 @@ describe('useAnalytics conversation product events', () => {
trigger_method: 'button',
trigger_type: 'user_flow_result',
})
expect(analyticsMocks.posthogCaptureMock).toHaveBeenCalledTimes(2)
expect(analyticsMocks.captureMock).toHaveBeenCalledTimes(2)
})
it('marks provider and model selections as explicit user actions', () => {
@@ -470,14 +401,14 @@ describe('useAnalytics conversation product events', () => {
analytics.trackProviderClick('official-provider', 'consciousness')
analytics.trackModelSwitched('none', 'model-b')
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(1, 'provider_card_clicked', {
expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(1, 'provider_card_clicked', {
app_surface: 'web',
provider_id: 'official-provider',
module: 'consciousness',
trigger_method: 'provider_card',
trigger_type: 'user_action',
})
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(2, 'model_switched', {
expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(2, 'model_switched', {
app_surface: 'web',
from_model: 'none',
to_model: 'model-b',
@@ -529,17 +460,17 @@ describe('useAnalytics conversation product events', () => {
success: true,
})
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(1, 'onboarding_started', {
expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(1, 'onboarding_started', {
app_surface: 'web',
entry: 'app_start',
})
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(2, 'onboarding_completed', {
expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(2, 'onboarding_completed', {
app_surface: 'web',
selected_provider_type: 'official',
selected_provider_id: 'official-provider',
selected_use_case: 'role_chat',
})
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(3, 'message_sent', {
expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(3, 'message_sent', {
app_surface: 'web',
conversation_id: 'session-1',
provider_type: 'official',
@@ -555,18 +486,18 @@ describe('useAnalytics conversation product events', () => {
trigger_method: 'text_input',
trigger_type: 'user_action',
})
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(4, 'quota_limit_reached', {
expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(4, 'quota_limit_reached', {
limit_type: 'flux',
current_usage: 0,
limit_value: 0,
entry: 'pricing',
})
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(5, 'upgrade_clicked', {
expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(5, 'upgrade_clicked', {
source_page: 'settings_flux',
current_plan: 'flux',
trigger: 'manual_topup',
})
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(6, 'feature_used', {
expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(6, 'feature_used', {
app_surface: 'web',
feature_name: 'chat',
business_domain: 'conversation',
@@ -620,50 +551,50 @@ describe('useAnalytics conversation product events', () => {
category: 'payment',
})
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(1, 'conversation_created', {
expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(1, 'conversation_created', {
app_surface: 'web',
conversation_id: 'session-1',
source: 'new_session',
character_id: 'character-1',
cloud_synced: true,
})
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(2, 'conversation_renamed', {
expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(2, 'conversation_renamed', {
app_surface: 'web',
conversation_id: 'session-1',
source: 'sessions_drawer',
})
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(3, 'conversation_shared', {
expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(3, 'conversation_shared', {
app_surface: 'web',
conversation_id: 'session-1',
source: 'share_button',
})
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(4, 'conversation_deleted', {
expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(4, 'conversation_deleted', {
app_surface: 'web',
conversation_id: 'session-1',
message_count: 6,
cloud_synced: true,
})
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(5, 'attachment_uploaded', {
expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(5, 'attachment_uploaded', {
app_surface: 'web',
attachment_type: 'image',
size_bytes: 2048,
source: 'chat',
success: true,
})
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(6, 'preset_used', {
expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(6, 'preset_used', {
app_surface: 'web',
preset_id: 'preset-live2d-1',
preset_type: 'stage_model',
source: 'settings',
})
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(7, 'settings_changed', {
expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(7, 'settings_changed', {
app_surface: 'web',
setting_name: 'analytics_enabled',
previous_value: false,
new_value: true,
source: 'settings',
})
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(8, 'support_contacted', {
expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(8, 'support_contacted', {
app_surface: 'web',
channel: 'discord',
source: 'settings',
@@ -674,7 +605,7 @@ describe('useAnalytics conversation product events', () => {
/**
* @example
* analytics.trackBugReportSubmitted({ source: 'app', category: 'update', severity: 'major', user_type: 'unknown', entrypoint: 'about_update_error', description_length_bucket: 'medium', include_triage_context: true, screenshot_attached: true })
* expect(posthog.capture).toHaveBeenCalledWith('bug_report_submitted', expect.objectContaining({ category: 'update' }))
* expect(captureAnalyticsEvent).toHaveBeenCalledWith('bug_report_submitted', expect.objectContaining({ category: 'update' }))
*/
it('emits feedback and bug report events with community triage tags', () => {
const analytics = useAnalytics()
@@ -697,7 +628,7 @@ describe('useAnalytics conversation product events', () => {
entrypoint: 'community_manual_tag',
})
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(1, 'bug_report_submitted', {
expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(1, 'bug_report_submitted', {
app_surface: 'web',
source: 'app',
category: 'update',
@@ -708,7 +639,7 @@ describe('useAnalytics conversation product events', () => {
include_triage_context: true,
screenshot_attached: true,
})
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(2, 'feedback_submitted', {
expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(2, 'feedback_submitted', {
app_surface: 'web',
source: 'discord',
category: 'voice_input',
@@ -728,20 +659,20 @@ describe('useAnalytics conversation product events', () => {
analytics.trackAccountDeletionRequested()
analytics.trackOauthCallbackFailed({ stage: 'missing_flow_state' })
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(1, 'password_changed', { app_surface: 'web' })
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(2, 'password_reset_requested', { app_surface: 'web' })
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(
expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(1, 'password_changed', { app_surface: 'web' })
expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(2, 'password_reset_requested', { app_surface: 'web' })
expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(
3,
'oauth_provider_link_started',
{ app_surface: 'web', provider: 'github' },
{ beforeNavigation: true },
)
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(4, 'oauth_provider_unlinked', {
expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(4, 'oauth_provider_unlinked', {
app_surface: 'web',
provider: 'google',
})
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(5, 'account_deletion_requested', { app_surface: 'web' })
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(6, 'oauth_callback_failed', {
expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(5, 'account_deletion_requested', { app_surface: 'web' })
expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(6, 'oauth_callback_failed', {
app_surface: 'web',
stage: 'missing_flow_state',
})
@@ -755,21 +686,21 @@ describe('useAnalytics conversation product events', () => {
analytics.trackSceneBackgroundSet({ source: 'scene_settings', cleared: true })
analytics.trackCharacterUpdated({ character_id: 'character-1' })
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(1, 'card_edited', {
expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(1, 'card_edited', {
app_surface: 'web',
card_id: 'card-1',
})
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(2, 'scene_background_set', {
expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(2, 'scene_background_set', {
app_surface: 'web',
source: 'card_gallery',
cleared: false,
})
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(3, 'scene_background_set', {
expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(3, 'scene_background_set', {
app_surface: 'web',
source: 'scene_settings',
cleared: true,
})
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(4, 'character_updated', {
expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(4, 'character_updated', {
character_id: 'character-1',
})
})
@@ -780,11 +711,11 @@ describe('useAnalytics conversation product events', () => {
analytics.trackDataAction({ action: 'chats_exported' })
analytics.trackDataAction({ action: 'app_data_cleared' })
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(1, 'data_action', {
expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(1, 'data_action', {
app_surface: 'web',
action: 'chats_exported',
})
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(2, 'data_action', {
expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(2, 'data_action', {
app_surface: 'web',
action: 'app_data_cleared',
})
@@ -797,11 +728,11 @@ describe('useAnalytics conversation product events', () => {
analytics.trackControlsIslandAction({ action: 'toggle_chat' })
analytics.trackControlsIslandAction({ action: 'refresh_window' })
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(1, 'controls_island_action', {
expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(1, 'controls_island_action', {
action: 'toggle_chat',
environment: 'tamagotchi',
})
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(
expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(
2,
'controls_island_action',
{
@@ -826,19 +757,19 @@ describe('useAnalytics conversation product events', () => {
analytics.trackMcpConnectionTestRun({ success: false })
analytics.trackDevicePairingQrShown()
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(1, 'spotlight_used', {})
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(2, 'widget_opened', { widget_id: 'weather' })
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(3, 'update_check_clicked', { channel: 'auto' })
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(4, 'update_downloaded', { channel: 'stable', version: '0.11.0' })
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(
expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(1, 'spotlight_used', {})
expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(2, 'widget_opened', { widget_id: 'weather' })
expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(3, 'update_check_clicked', { channel: 'auto' })
expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(4, 'update_downloaded', { channel: 'stable', version: '0.11.0' })
expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(
5,
'update_install_clicked',
{ channel: 'stable', version: '0.11.0' },
{ beforeNavigation: true },
)
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(6, 'mcp_server_updated', { action: 'add' })
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(7, 'mcp_server_updated', { action: 'remove' })
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(8, 'mcp_connection_test_run', { success: false })
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(9, 'device_pairing_qr_shown', {})
expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(6, 'mcp_server_updated', { action: 'add' })
expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(7, 'mcp_server_updated', { action: 'remove' })
expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(8, 'mcp_connection_test_run', { success: false })
expect(analyticsMocks.captureMock).toHaveBeenNthCalledWith(9, 'device_pairing_qr_shown', {})
})
})
@@ -159,7 +159,7 @@ export function useAnalytics() {
*
* Expects:
* - `entry_surface` is a stable identifier don't rename without coordinating
* PostHog funnel definitions in `docs/ai-context/metrics-ownership.md`.
* OpenPanel funnel definitions in `docs/ai-context/metrics-ownership.md`.
*/
function trackPricingViewed(entrySurface: string, planPeriod?: 'monthly' | 'annual' | 'one_time') {
if (!canCapture())
@@ -187,7 +187,7 @@ export function useAnalytics() {
* `window.location.href = ...`. `beforeNavigation` lets the installed
* adapter choose a delivery mechanism that survives document unload.
*
* The funnel terminator `payment_completed` is forwarded to PostHog
* The funnel terminator `payment_completed` is forwarded to OpenPanel
* server-side by the product-events service, keyed by the Better Auth
* user id.
*/
@@ -233,7 +233,7 @@ export function useAnalytics() {
}
// ─── Account lifecycle (same event names as apps/ui-server-auth's
// analytics module — both surfaces feed one PostHog series) ───────────
// analytics module — both surfaces feed one OpenPanel series) ───────────
function trackPasswordChanged() {
if (!canCapture())
@@ -308,7 +308,7 @@ export function useAnalytics() {
captureAnalyticsEvent('character_created', properties)
}
/** Feature adoption — voice mode is a candidate retention lever; cohort comparisons live in PostHog. */
/** Feature adoption — voice mode is a candidate retention lever; cohort comparisons live in OpenPanel. */
function trackVoiceModeActivated(characterId?: string) {
if (!canCapture())
return
@@ -336,7 +336,7 @@ export function useAnalytics() {
/**
* Retention cohort denominator every chat session start. Pair with
* `payment_completed` cohort to compute "active paying user" retention
* curves in PostHog.
* curves in OpenPanel.
*/
function trackChatSessionStarted(modelId: string, sessionIndex?: number) {
if (!canCapture())
@@ -344,49 +344,6 @@ export function useAnalytics() {
captureAnalyticsEvent('chat_session_started', { model_id: modelId, ...(sessionIndex != null && { session_index: sessionIndex }) })
}
/** Cost-fact event for one custom-provider generation; content is intentionally excluded. */
function trackAiGeneration(properties: {
conversation_id: string
round_id: string
provider_type: ProviderMode
provider_id: string
model_id: string
usage_source: AiUsageSource
input_tokens?: number
output_tokens?: number
total_tokens?: number
}) {
if (!canCapture())
return
const totalTokens = properties.total_tokens
?? (properties.input_tokens != null && properties.output_tokens != null
? properties.input_tokens + properties.output_tokens
: undefined)
captureAnalyticsEvent('$ai_generation', {
$ai_trace_id: properties.conversation_id,
$ai_session_id: properties.conversation_id,
$ai_span_id: properties.round_id,
$ai_model: properties.model_id,
$ai_provider: properties.provider_id,
...(properties.input_tokens != null && { $ai_input_tokens: properties.input_tokens }),
...(properties.output_tokens != null && { $ai_output_tokens: properties.output_tokens }),
...(totalTokens != null && { $ai_total_tokens: totalTokens }),
$insert_id: `ai-generation:${properties.round_id}`,
app_surface: getConversationAnalyticsSurface(),
capture_surface: 'client',
conversation_id: properties.conversation_id,
conversation_id_source: 'client_runtime',
round_id: properties.round_id,
provider_type: properties.provider_type,
usage_source: properties.usage_source,
token_usage_available: properties.usage_source !== 'unavailable',
cost_usd_source: 'unavailable',
cost_usd_known: false,
})
}
/** Closing event for one full message round (user send → assistant render). */
function trackMessageRound(properties: ChatRoundCorrelationProperties & {
duration_ms: number
@@ -1047,7 +1004,6 @@ export function useAnalytics() {
trackModelSwitched,
trackChatSessionStarted,
trackAiGeneration,
trackMessageRound,
trackMessageRoundFailed,
trackMessageSent,
+13 -13
View File
@@ -4,7 +4,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useAuthStore } from '../stores/auth'
import { authedFetch } from './auth-fetch'
const posthogMocks = vi.hoisted(() => ({
const analyticsMocks = vi.hoisted(() => ({
getAnalyticsIdentitySnapshot: vi.fn<() => { distinctId: string, sessionId: string } | null>(() => ({
distinctId: 'distinct-1',
sessionId: 'session-1',
@@ -12,7 +12,7 @@ const posthogMocks = vi.hoisted(() => ({
}))
vi.mock('./product-signals', () => ({
getAnalyticsIdentitySnapshot: posthogMocks.getAnalyticsIdentitySnapshot,
getAnalyticsIdentitySnapshot: analyticsMocks.getAnalyticsIdentitySnapshot,
}))
describe('authedFetch', () => {
@@ -20,13 +20,13 @@ describe('authedFetch', () => {
vi.restoreAllMocks()
setActivePinia(createPinia())
useAuthStore().token = 'access-token'
posthogMocks.getAnalyticsIdentitySnapshot.mockReturnValue({
analyticsMocks.getAnalyticsIdentitySnapshot.mockReturnValue({
distinctId: 'distinct-1',
sessionId: 'session-1',
})
})
it('sends PostHog identity headers with authenticated API requests', async () => {
it('sends analytics identity headers with authenticated API requests', async () => {
const fetchMock = vi.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise<Response>>(async () => new Response('{}', { status: 200 }))
vi.stubGlobal('fetch', fetchMock)
@@ -37,12 +37,12 @@ describe('authedFetch', () => {
const headers = fetchMock.mock.calls[0]?.[1]?.headers
expect(headers).toBeInstanceOf(Headers)
expect((headers as Headers).get('Authorization')).toBe('Bearer access-token')
expect((headers as Headers).get('x-posthog-distinct-id')).toBe('distinct-1')
expect((headers as Headers).get('x-posthog-session-id')).toBe('session-1')
expect((headers as Headers).get('x-openpanel-device-id')).toBe('distinct-1')
expect((headers as Headers).get('x-openpanel-session-id')).toBe('session-1')
})
it('omits PostHog identity headers when analytics has no active identity', async () => {
posthogMocks.getAnalyticsIdentitySnapshot.mockReturnValue(null)
it('omits analytics identity headers when analytics has no active identity', async () => {
analyticsMocks.getAnalyticsIdentitySnapshot.mockReturnValue(null)
const fetchMock = vi.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise<Response>>(async () => new Response('{}', { status: 200 }))
vi.stubGlobal('fetch', fetchMock)
@@ -51,11 +51,11 @@ describe('authedFetch', () => {
const headers = fetchMock.mock.calls[0]?.[1]?.headers
expect(headers).toBeInstanceOf(Headers)
expect((headers as Headers).get('Authorization')).toBe('Bearer access-token')
expect((headers as Headers).get('x-posthog-distinct-id')).toBeNull()
expect((headers as Headers).get('x-posthog-session-id')).toBeNull()
expect((headers as Headers).get('x-openpanel-device-id')).toBeNull()
expect((headers as Headers).get('x-openpanel-session-id')).toBeNull()
})
it('does not send PostHog identity headers to non-server origins', async () => {
it('does not send analytics identity headers to non-server origins', async () => {
const fetchMock = vi.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise<Response>>(async () => new Response('{}', { status: 200 }))
vi.stubGlobal('fetch', fetchMock)
@@ -64,7 +64,7 @@ describe('authedFetch', () => {
const headers = fetchMock.mock.calls[0]?.[1]?.headers
expect(headers).toBeInstanceOf(Headers)
expect((headers as Headers).get('Authorization')).toBe('Bearer access-token')
expect((headers as Headers).get('x-posthog-distinct-id')).toBeNull()
expect((headers as Headers).get('x-posthog-session-id')).toBeNull()
expect((headers as Headers).get('x-openpanel-device-id')).toBeNull()
expect((headers as Headers).get('x-openpanel-session-id')).toBeNull()
})
})
+6 -6
View File
@@ -27,11 +27,11 @@ export async function authedFetch(
const headers = new Headers(init?.headers)
if (token)
headers.set('Authorization', `Bearer ${token}`)
const posthogIdentity = shouldAttachPosthogIdentity(input) ? getAnalyticsIdentitySnapshot() : null
if (posthogIdentity) {
headers.set('x-posthog-distinct-id', posthogIdentity.distinctId)
if (posthogIdentity.sessionId)
headers.set('x-posthog-session-id', posthogIdentity.sessionId)
const openpanelIdentity = shouldAttachOpenpanelIdentity(input) ? getAnalyticsIdentitySnapshot() : null
if (openpanelIdentity) {
headers.set('x-openpanel-device-id', openpanelIdentity.distinctId)
if (openpanelIdentity.sessionId)
headers.set('x-openpanel-session-id', openpanelIdentity.sessionId)
}
return fetch(input, { ...init, headers, credentials: 'omit' })
}
@@ -59,7 +59,7 @@ export async function authedFetch(
return retried
}
function shouldAttachPosthogIdentity(input: RequestInfo | URL): boolean {
function shouldAttachOpenpanelIdentity(input: RequestInfo | URL): boolean {
const url = typeof input === 'string'
? input
: input instanceof URL ? input.toString() : input.url
@@ -186,7 +186,7 @@ export function configureAnalyticsAdapter(loader: AnalyticsAdapterLoader): void
}
export function isAnalyticsAvailableInBuild(): boolean {
return isEnvTruthy(import.meta.env.VITE_ENABLE_POSTHOG)
return isEnvTruthy(import.meta.env.VITE_ENABLE_ANALYTICS)
}
export function enableAnalyticsCapture(): boolean {
@@ -1,13 +0,0 @@
import { defineEvent } from '../../../utils/dsl'
export const aiGenerationEvent = defineEvent<{
conversation_id: string
round_id: string
provider_type: 'official' | 'custom' | 'unknown'
provider_id: string
model_id: string
usage_source: 'reported' | 'estimated' | 'unavailable'
input_tokens?: number
output_tokens?: number
total_tokens?: number
}>('$ai_generation')
@@ -1,3 +1,2 @@
export * from './generation'
export * from './message'
export * from './round'
@@ -2,7 +2,7 @@ import type { AnalyticsRecorder } from '../../index'
import { describe, expect, it, vi } from 'vitest'
import { aiGenerationEvent, messageSentEvent } from './events'
import { messageRoundEvent, messageSentEvent } from './events'
import { createChatAnalyticsHooks } from './runtime'
function createRecorder(): AnalyticsRecorder {
@@ -76,44 +76,34 @@ describe('createChatAnalyticsHooks', () => {
expect(analytics.recordFirstMessage).toHaveBeenCalledOnce()
})
it('records generation usage only for custom providers', () => {
it('keeps token usage on completed rounds without a second generation event', () => {
const analytics = createRecorder()
const hooks = createChatAnalyticsHooks({
analytics,
getSessionMessages: () => [],
})
const hooks = createChatAnalyticsHooks({ analytics, getSessionMessages: () => [] })
hooks.onLlmGeneration?.({
expect(hooks).not.toHaveProperty('onLlmGeneration')
hooks.onMessageRound?.({
conversationId: 'session-1',
roundId: 'round-1',
turnIndex: 1,
model: 'custom-model',
provider: 'custom-provider',
durationMs: 120,
hasVoice: false,
inputTokens: 12,
outputTokens: 8,
totalTokens: 20,
usageSource: 'reported',
})
hooks.onLlmGeneration?.({
conversationId: 'session-1',
roundId: 'round-2',
turnIndex: 2,
model: 'official-model',
provider: 'official-provider-chat',
usageSource: 'reported',
})
expect(analytics.emit).toHaveBeenCalledTimes(1)
expect(analytics.emit).toHaveBeenCalledWith(aiGenerationEvent, {
expect(analytics.emit).toHaveBeenCalledWith(messageRoundEvent, expect.objectContaining({
conversation_id: 'session-1',
round_id: 'round-1',
provider_type: 'custom',
provider_id: 'custom-provider',
model_id: 'custom-model',
usage_source: 'reported',
model: 'custom-model',
duration_ms: 120,
input_tokens: 12,
output_tokens: 8,
total_tokens: 20,
})
usage_source: 'reported',
}))
})
})
@@ -5,7 +5,6 @@ import type { AnalyticsRecorder } from '../../index'
import { getAnalytics } from '../../index'
import {
aiGenerationEvent,
messageRoundEvent,
messageRoundFailedEvent,
messageSentEvent,
@@ -14,7 +13,6 @@ import { getProviderMode } from './types'
type ChatAnalyticsCallbacks = Pick<
ChatOrchestratorRuntimeDeps,
| 'onLlmGeneration'
| 'onMessageRound'
| 'onMessageRoundFailed'
| 'onTrackFirstMessage'
@@ -40,23 +38,6 @@ export function createChatAnalyticsHooks(options: CreateChatAnalyticsHooksOption
return {
onTrackFirstMessage: () => analytics.recordFirstMessage(),
onLlmGeneration: ({ conversationId, roundId, model, provider, inputTokens, outputTokens, totalTokens, usageSource }) => {
const providerType = getProviderMode(provider)
if (providerType !== 'custom')
return
analytics.emit(aiGenerationEvent, {
conversation_id: conversationId,
round_id: roundId,
provider_type: providerType,
provider_id: provider,
model_id: model,
usage_source: usageSource,
input_tokens: inputTokens,
output_tokens: outputTokens,
total_tokens: totalTokens,
})
},
onMessageRound: ({ conversationId, roundId, turnIndex, durationMs, hasVoice, model, inputTokens, outputTokens, totalTokens, usageSource }) => {
analytics.emit(messageRoundEvent, {
conversation_id: conversationId,
@@ -0,0 +1,65 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createOpenpanelAdapter } from './openpanel'
afterEach(() => {
vi.restoreAllMocks()
})
describe('openPanel browser adapter', () => {
it('drops disabled events and isolates account identity across logout', async () => {
const requests: RequestInit[] = []
const destinations: string[] = []
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => {
destinations.push(String(input))
if (init)
requests.push(init)
return new Response(JSON.stringify({ deviceId: 'server-device', sessionId: 'session-1' }), { status: 200 })
})
const adapter = createOpenpanelAdapter({ enabled: false })
expect(adapter.capture('disabled_event', {})).toBe(false)
expect(adapter.getIdentitySnapshot()).toBeNull()
expect(adapter.setCaptureEnabled(true)).toBe(true)
const firstDevice = adapter.getIdentitySnapshot()?.distinctId
expect(firstDevice).toBeTruthy()
adapter.identify('alice')
adapter.capture('checkout_started', {}, { beforeNavigation: true })
await vi.waitFor(() => expect(requests.some(request => String(request.body).includes('checkout_started'))).toBe(true))
const checkout = requests.find(request => String(request.body).includes('checkout_started'))!
expect(checkout.keepalive).toBe(true)
expect(JSON.parse(String(checkout.body)).payload.profileId).toBe('alice')
expect(JSON.parse(String(checkout.body)).payload.properties.__deviceId).toBe(firstDevice)
const originalUrl = window.location.href
history.replaceState(null, '', `${window.location.pathname}?code=private#fragment`)
await vi.waitFor(() => expect(requests.some(request => String(request.body).includes('screen_view'))).toBe(true))
await new Promise(resolve => setTimeout(resolve, 80))
adapter.capture('navigation_metadata', {})
await vi.waitFor(() => expect(requests.some(request => String(request.body).includes('navigation_metadata'))).toBe(true))
const navigation = requests.find(request => String(request.body).includes('navigation_metadata'))!
expect(String(navigation.body)).not.toContain('code=private')
expect(String(navigation.body)).not.toContain('#fragment')
expect(JSON.parse(String(navigation.body)).payload.properties.__path).toBe(originalUrl.split(/[?#]/, 1)[0])
expect(requests.some(request => String(request.body).includes('code=private'))).toBe(false)
adapter.resetIdentity()
expect(adapter.getIdentitySnapshot()?.distinctId).not.toBe(firstDevice)
adapter.identify('bob')
adapter.capture('new_account', {})
await vi.waitFor(() => expect(requests.some(request => String(request.body).includes('new_account'))).toBe(true))
const bob = requests.find(request => String(request.body).includes('new_account'))!
expect(JSON.parse(String(bob.body)).payload.profileId).toBe('bob')
expect(JSON.parse(String(bob.body)).payload.properties.__deviceId).not.toBe(firstDevice)
adapter.setCaptureEnabled(false)
history.replaceState(null, '', originalUrl)
adapter.capture('disabled_event', {})
adapter.setCaptureEnabled(true)
adapter.capture('enabled_event', {})
await vi.waitFor(() => expect(requests.some(request => String(request.body).includes('enabled_event'))).toBe(true))
expect(requests.some(request => String(request.body).includes('disabled_event'))).toBe(false)
expect(destinations.every(url => url.startsWith('https://analytics.airi.build/api/'))).toBe(true)
expect(requests.every(request => !new Headers(request.headers).has('openpanel-client-secret'))).toBe(true)
adapter.setCaptureEnabled(false)
})
})
@@ -0,0 +1,116 @@
import type { AnalyticsAdapter, AnalyticsAdapterOptions } from './client'
import { OpenPanel } from '@openpanel/web'
import { isStageCapacitor, isStageTamagotchi } from '@proj-airi/stage-shared'
import { OPENPANEL_CONFIG } from '@proj-airi/stage-shared/analytics/openpanel'
const deviceStorageKey = 'airi:openpanel-device-id'
function loadDeviceId(): string {
try {
const stored = sessionStorage.getItem(deviceStorageKey)
if (stored)
return stored
}
catch {
// Storage can be unavailable in embedded browsers. Keep this visit in memory.
}
return rotateDeviceId()
}
function rotateDeviceId(): string {
const id = crypto.randomUUID()
try {
sessionStorage.setItem(deviceStorageKey, id)
}
catch {
// The in-memory identity still isolates accounts when storage is unavailable.
}
return id
}
/** Sends product events to OpenPanel under the current consent and identity state. */
export function createOpenpanelAdapter(options: AnalyticsAdapterOptions): AnalyticsAdapter {
let enabled = options.enabled
// Rotate the device on logout. Clearing SDK fields alone reuses its
// server-derived fingerprint and can link two accounts in one browser.
let deviceId = enabled ? loadDeviceId() : undefined
const panel = new OpenPanel({
...OPENPANEL_CONFIG,
// Consent must drop events. The SDK's disabled option queues them instead.
filter(payload) {
if (!enabled)
return false
// OAuth codes and other query values must not enter analytics.
if (payload.type === 'track' && payload.payload.properties) {
for (const key of ['__path', '__referrer']) {
const value = payload.payload.properties[key]
if (typeof value === 'string')
payload.payload.properties[key] = value.split(/[?#]/, 1)[0]
}
}
return true
},
trackScreenViews: true,
trackOutgoingLinks: false,
trackAttributes: false,
})
panel.setGlobalProperties({
app_surface: isStageTamagotchi() ? 'electron' : isStageCapacitor() ? 'mobile' : 'web',
__deviceId: deviceId,
})
return {
capture(name, properties) {
if (!enabled)
return false
// The SDK uses fetch keepalive for normal events, including navigation.
void panel.track(name, { ...properties, __deviceId: deviceId }).catch(() => console.warn('[analytics] Product event delivery failed'))
return true
},
getIdentitySnapshot() {
if (!enabled)
return null
if (!deviceId)
return null
return { distinctId: deviceId }
},
identify(userId) {
if (!enabled)
return
panel.identify({ profileId: userId })
},
registerBuildInfo(buildInfo) {
panel.setGlobalProperties({
app_branch: buildInfo.branch,
app_build_time: buildInfo.builtOn,
app_commit: buildInfo.commit,
app_version: buildInfo.version && buildInfo.version !== '0.0.0' ? buildInfo.version : 'dev',
})
},
resetIdentity() {
panel.clear()
deviceId = enabled ? rotateDeviceId() : undefined
panel.setGlobalProperties({ __deviceId: deviceId })
},
setCaptureEnabled(value) {
enabled = value
if (!value) {
panel.clear()
deviceId = undefined
try {
sessionStorage.removeItem(deviceStorageKey)
}
catch {
// Capture remains disabled even when browser storage is unavailable.
}
}
else if (!deviceId) {
deviceId = rotateDeviceId()
}
panel.setGlobalProperties({ __deviceId: deviceId })
return enabled
},
}
}
@@ -1,52 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
import { createPosthogAdapter } from './posthog'
const posthogMocks = vi.hoisted(() => ({
capture: vi.fn(),
get_distinct_id: vi.fn(() => 'distinct-1'),
get_session_id: vi.fn(() => 'session-1'),
has_opted_out_capturing: vi.fn(() => false),
init: vi.fn(),
register: vi.fn(),
}))
vi.mock('posthog-js', () => ({ default: posthogMocks }))
vi.mock('@proj-airi/stage-shared', () => ({
isStageCapacitor: () => false,
isStageTamagotchi: () => false,
}))
vi.mock('@proj-airi/stage-shared/analytics/posthog', () => ({
DEFAULT_POSTHOG_CONFIG: {},
POSTHOG_PROJECT_KEY: 'test-project-key',
}))
describe('posthog analytics adapter', () => {
it('registers the runtime under the dedicated app_surface property', () => {
createPosthogAdapter({ enabled: true })
expect(posthogMocks.register).toHaveBeenCalledWith({ app_surface: 'web' })
})
it('exposes the current provider identity for server-side conversion linking', () => {
const adapter = createPosthogAdapter({ enabled: true })
expect(adapter.getIdentitySnapshot()).toEqual({
distinctId: 'distinct-1',
sessionId: 'session-1',
})
})
it('maps the provider-neutral navigation hint to unload-safe delivery', () => {
const adapter = createPosthogAdapter({ enabled: true })
adapter.capture('checkout_started', { plan_id: 'monthly' }, { beforeNavigation: true })
expect(posthogMocks.capture).toHaveBeenCalledWith(
'checkout_started',
{ plan_id: 'monthly' },
{ send_instantly: true, transport: 'sendBeacon' },
)
})
})
@@ -1,79 +0,0 @@
import type { AboutBuildInfo } from '../../components/scenarios/about/types'
import type { AnalyticsAdapter, AnalyticsAdapterOptions } from './client'
import posthog from 'posthog-js'
import { isStageCapacitor, isStageTamagotchi } from '@proj-airi/stage-shared'
import {
DEFAULT_POSTHOG_CONFIG,
POSTHOG_PROJECT_KEY,
} from '@proj-airi/stage-shared/analytics/posthog'
/** Creates and initializes the default PostHog adapter. */
export function createPosthogAdapter(options: AnalyticsAdapterOptions): AnalyticsAdapter {
posthog.init(POSTHOG_PROJECT_KEY, {
...DEFAULT_POSTHOG_CONFIG,
opt_out_capturing_by_default: !options.enabled,
})
posthog.register({ app_surface: currentSurface() })
return {
capture(name, properties, captureOptions) {
if (posthog.has_opted_out_capturing())
return false
posthog.capture(
name,
{ ...properties },
captureOptions?.beforeNavigation
? { send_instantly: true, transport: 'sendBeacon' }
: undefined,
)
return true
},
getIdentitySnapshot() {
if (posthog.has_opted_out_capturing())
return null
const distinctId = posthog.get_distinct_id()
if (!distinctId)
return null
const sessionId = posthog.get_session_id()
return { distinctId, ...(sessionId && { sessionId }) }
},
identify(userId) {
if (!posthog.has_opted_out_capturing())
posthog.identify(userId)
},
registerBuildInfo(buildInfo: AboutBuildInfo) {
posthog.register({
app_branch: buildInfo.branch,
app_build_time: buildInfo.builtOn,
app_commit: buildInfo.commit,
app_version: (buildInfo.version && buildInfo.version !== '0.0.0') ? buildInfo.version : 'dev',
})
},
resetIdentity() {
posthog.reset()
},
setCaptureEnabled(enabled) {
if (enabled) {
if (posthog.has_opted_out_capturing())
posthog.opt_in_capturing()
return true
}
if (!posthog.has_opted_out_capturing())
posthog.opt_out_capturing()
return false
},
}
}
function currentSurface(): 'electron' | 'mobile' | 'web' {
if (isStageTamagotchi())
return 'electron'
if (isStageCapacitor())
return 'mobile'
return 'web'
}
@@ -49,7 +49,6 @@ const ioTracerMocks = vi.hoisted(() => {
const llmStreamMock = vi.fn()
const trackFirstMessageMock = vi.fn()
const chatAnalyticsMocks = vi.hoisted(() => ({
trackAiGeneration: vi.fn(),
trackMessageRound: vi.fn(),
trackMessageRoundFailed: vi.fn(),
trackMessageSent: vi.fn(),
@@ -99,9 +98,6 @@ vi.mock('../libs/product-signals', () => ({
getAnalytics: () => ({
emit: (event: { name: string }, properties: unknown) => {
switch (event.name) {
case '$ai_generation':
chatAnalyticsMocks.trackAiGeneration(properties)
break
case 'message_round':
chatAnalyticsMocks.trackMessageRound(properties)
break
@@ -450,7 +446,7 @@ describe('chat store contract', () => {
expect(chatAnalyticsMocks.trackMessageRound).toHaveBeenCalledWith(expect.objectContaining(correlation))
})
it('captures custom-provider usage once and leaves official generation capture to the server', async () => {
it('keeps custom and official provider usage in completed message rounds', async () => {
llmStreamMock.mockImplementation(async (_model: string, _chatProvider: ChatProvider, _messages: Message[], options: any) => {
await options.onStreamEvent({ type: 'text-delta', text: 'ok' })
await options.onStreamEvent({ type: 'finish', finishReason: 'stop' })
@@ -468,26 +464,26 @@ describe('chat store contract', () => {
chatProvider: provider,
})
expect(chatAnalyticsMocks.trackAiGeneration).toHaveBeenCalledWith({
conversation_id: 'session-1',
round_id: expect.any(String),
provider_type: 'custom',
provider_id: 'mock-provider',
model_id: 'gpt-test',
expect(chatAnalyticsMocks.trackMessageRound).toHaveBeenCalledTimes(1)
expect(chatAnalyticsMocks.trackMessageRound).toHaveBeenCalledWith(expect.objectContaining({
model: 'gpt-test',
usage_source: 'reported',
input_tokens: 12,
output_tokens: 8,
total_tokens: 20,
})
}))
chatAnalyticsMocks.trackAiGeneration.mockClear()
activeProviderRef.value = 'official-provider'
await store.ingest('official turn', {
model: 'chat-auto',
chatProvider: provider,
})
expect(chatAnalyticsMocks.trackAiGeneration).not.toHaveBeenCalled()
expect(chatAnalyticsMocks.trackMessageRound).toHaveBeenCalledTimes(2)
expect(chatAnalyticsMocks.trackMessageRound).toHaveBeenLastCalledWith(expect.objectContaining({
model: 'chat-auto',
total_tokens: 20,
}))
expect(llmStreamMock.mock.calls[1]?.[3]?.headers).toEqual({
[AIRI_CHAT_APP_SURFACE_HEADER]: 'web',
[AIRI_CHAT_SESSION_ID_HEADER]: 'session-1',
@@ -525,7 +521,7 @@ describe('chat store contract', () => {
// ROOT CAUSE:
//
// One successful send emitted both the canonical message/latency events
// and four generic aliases, multiplying PostHog volume without adding a
// and four generic aliases, multiplying event volume without adding a
// distinct product decision.
it('does not emit redundant generic chat aliases for a successful send', async () => {
llmStreamMock.mockImplementation(async (_model: string, _chatProvider: ChatProvider, _messages: Message[], options: any) => {
+85 -111
View File
@@ -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
View File
@@ -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
+1 -1
View File
@@ -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:",
-1
View File
@@ -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
View File
@@ -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', {
+3 -7
View File
@@ -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 }
+16 -12
View File
@@ -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',
feature: 'billing',
action: 'payment_completed',
status: 'succeeded',
metadata: { $insert_id: 'spoofed' },
})).resolves.toBeUndefined()
deviceId: 'browser-1',
event: 'payment_completed',
properties: {
openpanel_device_id: 'browser-1',
amount_total: 990,
currency: 'usd',
event_id: 'cs_123',
app_surface: 'server',
airi_user_id: 'user-1',
feature: 'billing',
status: 'succeeded',
},
})
})
it('rejects metadata that can forge provider identity or the source event id', async () => {
const capture = vi.fn(async () => {})
const service = createProductEventService({ capture })
for (const key of ['event_id', '__deviceId', 'profileId']) {
await service.track({ userId: 'user-1', feature: 'billing', action: 'payment_completed', status: 'succeeded', metadata: { [key]: 'forged' } })
}
expect(capture).not.toHaveBeenCalled()
})
it('still captures the funnel event when identity merging fails', async () => {
const capture = vi.fn()
.mockRejectedValueOnce(new Error('identify failed'))
.mockResolvedValueOnce(undefined)
const service = createProductEventService({ capture, shutdown: vi.fn(async () => {}) })
await expect(service.track({
userId: 'user-1',
feature: 'billing',
action: 'payment_completed',
status: 'succeeded',
eventId: 'cs_456',
metadata: { posthog_distinct_id: 'anon-browser-1' },
})).resolves.toBeUndefined()
expect(capture).toHaveBeenCalledTimes(2)
expect(capture).toHaveBeenNthCalledWith(2, expect.objectContaining({
event: 'payment_completed',
properties: expect.objectContaining({ $insert_id: 'cs_456' }),
}))
})
it('does not fail a business path when capture throws', async () => {
it('does not fail the business path when OpenPanel is unavailable', async () => {
const capture = vi.fn(async () => {
throw new Error('posthog exploded')
throw new Error('unavailable')
})
const service = createProductEventService({ capture, shutdown: vi.fn(async () => {}) })
await expect(service.track({
userId: 'user-1',
feature: 'billing',
action: 'payment_completed',
status: 'succeeded',
})).resolves.toBeUndefined()
const service = createProductEventService({ capture })
await expect(service.track({ userId: 'user-1', feature: 'billing', action: 'payment_completed', status: 'succeeded' })).resolves.toBeUndefined()
})
})
@@ -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')
}
},
}
+1 -2
View File
@@ -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 {