feat(analytics): expand PostHog coverage and forward server business facts (#2038)
Some checks are pending
CI / Lint (push) Waiting to run
CI / Build Test (stage-tamagotchi) (push) Waiting to run
CI / Build Test (stage-tamagotchi-godot) (push) Waiting to run
CI / Build Test (stage-web) (push) Waiting to run
CI / Build Test (ui-loading-screens) (push) Waiting to run
CI / Build Test (ui-transitions) (push) Waiting to run
CI / Unit Test (push) Waiting to run
CI / Type Check (push) Waiting to run
CI / Check Provenance (push) Waiting to run
Cloudflare Pages (Auth UI) / Deploy - ui-server-auth (push) Waiting to run
Cloudflare Workers / Deploy - stage-web (push) Waiting to run
Update Nix pnpmDeps Hash / update (push) Waiting to run

## What

Closes the three structural gaps in AIRI's product analytics: the signup
surface had zero instrumentation, the payment funnel had no terminator
in PostHog, and SPA route changes emitted no pageviews. Also adds
semantic events for character cards, desktop-only features, and
data-maintenance actions.

## User paths

- User signs up / logs in / verifies email / resets password / links
OAuth / deletes account → each step now emits a PostHog event from
`apps/ui-server-auth` (previously fully uninstrumented), with
`identify()` wired on session load so anonymous funnel events merge into
the user person.
- User pays via Stripe → webhook writes `product_events` as before, and
the product-events service now forwards `payment_completed` (plus signup
and subscription lifecycle facts) to PostHog via posthog-node
`captureImmediate`, keyed by the Better Auth user id → the
`checkout_started → payment_completed` funnel closes end-to-end.
Per-request LLM/TTS volume is explicitly not forwarded.
- User navigates between routes in any surface (web / desktop / pocket /
docs) → `$pageview` + `$pageleave` fire per route change via the
posthog-js `defaults: '2025-05-24'` preset in the shared
`posthog.config.ts`.
- User creates / imports / duplicates / edits a ccv3 card, switches
stage background, runs destructive data actions (export / import / clear
chats, reset providers, wipe app data), or uses desktop differentiators
(Spotlight send, widget windows, in-app updater, MCP server management,
pairing QR) → dedicated low-cardinality events.

## Notable decisions

- Server forwarding defaults on: `POSTHOG_PROJECT_KEY` defaults to the
shared browser-safe phc_* project key; set it to an empty string to
disable. Postgres `product_events` remains the source of truth.
- Cross-surface events (`oauth_callback_failed`, account lifecycle)
share one stage vocabulary exported from stage-ui so the two emitters
cannot drift silently.
- Events captured right before full-page navigation use `sendBeacon` so
they survive the redirect (checkout, OAuth consent handoff, login
redirect).
- Removed dead wrappers (`trackSignup`, `trackFirstModelSelected`,
`trackModelChanged`) that duplicated live event streams under second
names.

## How tested

- `pnpm -F @proj-airi/server exec vitest run
src/services/domain/product-events.test.ts` — 6 passed, covering the
forwarding allowlist, the `user_signed_up → signup_completed` mapping,
non-forwarded per-request actions, and a throwing sink not failing the
webhook path nor losing the DB row.
- stage-ui suites (`use-analytics`, `use-linked-accounts`, exports
contract) — 22 passed, including new account/card/data/desktop event
assertions.
- Real transport smoke: posthog-node `captureImmediate` against
`us.i.posthog.com` with the production key resolved in 1380ms (one
`server_forwarding_smoke_test` event left in the project; filter by
event name).
- Browser-tested pageviews: `VITE_ENABLE_POSTHOG=true` dev build, two
`history.pushState` route changes each produced a `$pageview` with
`$pathname`, `navigation_type: pushState`, previous-page dwell time, and
the `surface` super property; batched POST to `us.i.posthog.com/e/`
returned 200. Note: posthog-js drops events from automated browsers
(`navigator.webdriver`) by default — the verification session bypassed
the bot filter locally; production config is untouched.
- Typecheck and lint pass for server, stage-ui, stage-pages, stage-web,
stage-tamagotchi, ui-server-auth.

Full verification record:
`apps/server/docs/ai-context/verifications/posthog-forwarding-and-pageview.md`

## Follow-ups (not in this PR)

- Bot channel usage stats (Discord / Telegram) once they route through
server-runtime counters.
- Main-process desktop events (tray menu, global shortcut fire) need
renderer relay plumbing.
- Confirm `payment_completed` arrives in PostHog after the first real
Stripe payment post-deploy.

https://claude.ai/code/session_01Q1yGavkQ1P41YhTWE4XKex
This commit is contained in:
RainbowBird 2026-07-08 15:33:39 +08:00 committed by GitHub
parent 5aa44aedd3
commit 0642ede8d1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
46 changed files with 1133 additions and 92 deletions

View file

@ -130,7 +130,8 @@
| Conversation controls | `chat_message_deleted` / `chat_messages_cleared` / `chat_message_retried` | 前端 | `packages/stage-layouts/src/components/Layouts/*InteractiveArea.vue` / `packages/stage-layouts/src/components/Widgets/ChatActionButtons.vue` / `apps/stage-tamagotchi/src/renderer/components/InteractiveArea.vue` | PostHog |
| Conversation controls | `tts_stop_clicked` | 前端 | `packages/stage-layouts/src/composables/useStopSpeakingButton.ts` | PostHog |
| Churn | `subscription_cancelled`(带 cancellation_reason | 外部 Stripe 数据源 | PostHog Stripe source connector | Stripe/Postgres |
| 老事件 | `provider_card_clicked` / `first_message_sent` | 前端 | `packages/stage-ui/src/composables/use-analytics.ts` | PostHog |
| 老事件 | `provider_card_clicked` | 前端 | `packages/stage-ui/src/composables/use-analytics.ts` | PostHog |
| 已退役 | `first_message_sent` / `user_signed_up`(前端版) | 无生产者 | wrapper 已删除;激活口径用 `chat_activation_succeeded`,注册事实用服务端转发的 `signup_completed` | PostHog 历史数据仍在 |
待埋点API 已在 `use-analytics.ts` 暴露但调用点未接入):
@ -150,22 +151,21 @@
## PostHog 接入路线图
落地分两步,**不要一次性埋全部事件**,否则 schema 漂移会很快出现。PostHog 采集只发生在前端或外部 source connectorserver 请求路径只写 Postgres/Grafana。
落地分两步,**不要一次性埋全部事件**,否则 schema 漂移会很快出现。PostHog 采集以前端为主;服务端只经 product-events 白名单转发业务事实注册、支付、订阅per-request 路径仍只写 Postgres/Grafana。
### 阶段 1P0 — 付费漏斗 + activation
`apps/stage-web`
所有 surface 共用根目录 `posthog.config.ts`(单一 project key`surface` super property 区分端)。初始化实况
```ts
import posthog from 'posthog-js'
import { DEFAULT_POSTHOG_CONFIG, POSTHOG_PROJECT_KEY } from '../posthog.config'
posthog.init(import.meta.env.VITE_POSTHOG_KEY, {
api_host: 'https://us.i.posthog.com',
capture_pageview: false, // 手动 capture 控制语义
})
// 登录后
// DEFAULT_POSTHOG_CONFIG 内含 defaults: '2025-05-24'
// SPA 路由切换自动发 $pageview / $pageleave页面浏览不再手动埋。
posthog.init(POSTHOG_PROJECT_KEY, { ...DEFAULT_POSTHOG_CONFIG })
// 登录后stage 端在 analytics storeauth 端在 profile.vue
posthog.identify(user.id)
// 在 pricing.vue
// 在 flux.vue
posthog.capture('pricing_page_viewed', { plan_period, source })
```
@ -184,13 +184,13 @@ posthog.init(import.meta.env.VITE_POSTHOG_KEY, {
埋点事件清单P0
- 前端:`pricing_page_viewed``plan_selected``checkout_started``user_signed_up`、`first_message_sent``first_model_selected`
- 后端:不直接发 PostHog`payment_completed` 写入 `product_events`PostHog 侧通过 Stripe source connector 或离线导入展示
- 前端:`pricing_page_viewed``plan_selected``checkout_started``signup_completed`ui-server-auth 邮箱注册路径)`first_model_selected`
- 后端:`product_events` 是事实账本;其中业务事实白名单(`signup_completed``payment_completed``subscription_started/renewed/cancelled`)由 product-events 服务经 posthog-node 转发一份到 PostHog`apps/server/src/services/domain/product-events.ts`distinctId = Better Auth user id。LLM / TTS 等 per-request 事件不转发
PostHog UI 配两个 funnel
- **付费漏斗** (7d 窗口)`pricing_page_viewed → plan_selected → checkout_started → payment_completed`(最后一步来自 Stripe source connector / 离线导入
- **激活漏斗** (14d 窗口)`user_signed_up → first_message_sent → first_model_selected → payment_completed`(最后一步同上)
- **付费漏斗** (7d 窗口)`pricing_page_viewed → plan_selected → checkout_started → payment_completed`(最后一步来自服务端转发
- **激活漏斗** (14d 窗口)`signup_completed → onboarding_started → chat_activation_succeeded → payment_completed`
### 阶段 2P1 — retention / feature adoption / churn

View file

@ -0,0 +1,26 @@
# Verification: 服务端 PostHog 转发 + SPA 路由 pageview
Status: **transport 与 pageview 已真实验证;线上 Stripe webhook 端到端待部署后确认**
Owner: Product Analytics
Last updated: 2026-07-08
Environment: commit 689f02ac4 + 本次工作区改动posthog-node 5.39.4posthog-js 1.306.1Node 26.3.0
## 用户路径
- **场景 1**:用户完成 Stripe 支付 → webhook 写 `product_events` → 服务端把 `payment_completed` 转发到 PostHogdistinctId = Better Auth user id→ PostHog 付费漏斗在 `checkout_started` 后闭环。
- **场景 2**:用户在 stage-web 内切换路由(如进入 `/settings/flux`)→ PostHog 收到 `$pageview`,带 `$pathname`、上一页路径与停留时长。
## 已验证证据
| 验证项 | 命令 / 方式 | 实际输出 |
|---|---|---|
| 转发白名单与映射 | `pnpm exec vitest run src/services/domain/product-events.test.ts`apps/server | 6 passed`payment_completed` 原名转发、`user_signed_up→signup_completed` 映射、per-request 动作不转发、sink 抛错时 DB 行仍落库且 track 不抛 |
| posthog-node 真实传输 | `node posthog-smoke.mjs`captureImmediate → us.i.posthog.com生产 project key事件名 `server_forwarding_smoke_test` | `captureImmediate resolved in 1380ms` + `shutdown clean` |
| SPA 路由 pageview | `VITE_ENABLE_POSTHOG=true pnpm -F @proj-airi/stage-web dev` + agent-browser 两次 `history.pushState` | 两条 `$pageview``$pathname` 分别为 `/settings/flux``/settings/airi-card``navigation_type: pushState`,携带 `$prev_pageview_duration``surface: web` super property批量 POST `us.i.posthog.com/e/` 返回 200 |
| 服务端 typecheck / lint | `pnpm -F @proj-airi/server typecheck`、eslint 改动文件 | 均通过 |
## 注意事项
- 自动化浏览器(`navigator.webdriver = true`)会被 posthog-js 默认 bot 过滤静默丢弃事件;本次浏览器验证通过会话内 `set_config({ opt_out_useragent_filter: true })` 绕过,仅影响该验证会话,生产配置未改。人工复测时用普通浏览器即可,无需任何绕过。
- 服务端转发默认开启:`POSTHOG_PROJECT_KEY` 的默认值就是前端共用的 phc_* project key置空字符串可关闭。部署后在 PostHog 里确认 `payment_completed` 事件出现在真实支付后,即完成端到端收尾。
- PostHog 项目里会留有一条 `server_forwarding_smoke_test`distinctId `verification-smoke`)测试事件,分析时按事件名过滤。

View file

@ -61,6 +61,7 @@
"nanoid": "catalog:",
"ofetch": "catalog:",
"pg": "catalog:",
"posthog-node": "catalog:",
"resend": "catalog:",
"stripe": "catalog:",
"unspeech": "catalog:xsai",

View file

@ -75,6 +75,7 @@ import { createStripeRoutes } from './routes/stripe'
import { createVoicePackRoutes } from './routes/voice-packs'
import { createConfigKVService } from './services/adapters/config-kv'
import { createEmailService } from './services/adapters/email'
import { createPosthogSink } from './services/adapters/posthog'
import { createAdminFluxGrantsService } from './services/domain/admin/flux-grants'
import { createAdminRouterConfigService } from './services/domain/admin/router-config'
import { createAdminUsersService } from './services/domain/admin/users'
@ -593,9 +594,27 @@ export async function createApp() {
}, undefined, dependsOn.otel?.email),
})
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 '').
build: ({ dependsOn }) => {
if (!dependsOn.env.POSTHOG_PROJECT_KEY)
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
},
})
const productEventService = injeca.provide('services:productEvents', {
dependsOn: { db, otel },
build: ({ dependsOn }) => createProductEventService(dependsOn.db, dependsOn.otel?.product),
dependsOn: { db, otel, posthogSink },
build: ({ dependsOn }) => createProductEventService(dependsOn.db, dependsOn.otel?.product, dependsOn.posthogSink),
})
const characterService = injeca.provide('services:characters', {

View file

@ -175,6 +175,14 @@ const EnvSchema = object({
DB_POOL_CONNECTION_TIMEOUT_MS: optionalIntegerFromString(5000, 'DB_POOL_CONNECTION_TIMEOUT_MS', 1),
DB_POOL_KEEPALIVE_INITIAL_DELAY_MS: optionalIntegerFromString(10000, 'DB_POOL_KEEPALIVE_INITIAL_DELAY_MS', 1),
// PostHog product-event forwarding (signup / payment / subscription facts).
// Defaults to the shared AIRI project key (same browser-safe phc_* key the
// client surfaces embed in posthog.config.ts), so forwarding is on out of
// the box. Set to an empty string to disable; Postgres `product_events`
// stays the source of truth either way.
POSTHOG_PROJECT_KEY: optional(string(), 'phc_pzjziJjrVZpa9SqnQqq0QEKvkmuCPH7GDTA6TbRTEf9'), // cspell:disable-line
POSTHOG_API_HOST: optional(string(), 'https://us.i.posthog.com'),
// OpenTelemetry
OTEL_SERVICE_NAMESPACE: optional(string(), 'airi'),
OTEL_SERVICE_NAME: optional(string(), 'server'),

View file

@ -0,0 +1,59 @@
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>
}
/**
* 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 {
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.
*
* Uses `captureImmediate` (one HTTP roundtrip per event, no background
* queue) on purpose: the forwarded events are low-frequency business facts
* (signup, payment, subscription lifecycle) fired from webhook/auth-hook
* paths where a queued batch could be lost on process exit.
*
* Capture failures are logged and swallowed analytics forwarding must
* never fail the Stripe webhook or auth flow that triggered it. The
* Postgres `product_events` row is the source of truth either way.
*/
export function createPosthogSink(options: { projectKey: string, host: string }): PosthogSink {
const client = new PostHog(options.projectKey, { host: options.host })
return {
async capture(input: PosthogCaptureInput): Promise<void> {
try {
await client.captureImmediate({
distinctId: input.distinctId,
event: input.event,
properties: input.properties,
})
}
catch (err) {
logger.withError(err).withFields({ event: input.event }).warn('Failed to forward product event to PostHog')
}
},
async shutdown(): Promise<void> {
await client.shutdown()
},
}
}

View file

@ -1,6 +1,7 @@
import type { Database } from '../../libs/db'
import type { ProductMetrics } from '../../otel'
import { sql } from 'drizzle-orm'
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { mockDB } from '../../libs/mock-db'
@ -136,4 +137,137 @@ describe('productEventService', () => {
flux_balance_bucket: 'zero',
})
})
it('forwards allowlisted business facts to PostHog keyed by user id, mapping user_signed_up to signup_completed', async () => {
const capture = vi.fn(async () => {})
const sink = { capture, shutdown: vi.fn(async () => {}) }
const service = createProductEventService(db, null, sink)
await service.track({
userId: 'user-1',
feature: 'billing',
action: 'payment_completed',
status: 'succeeded',
source: 'stripe.webhook',
metadata: { amount_minor_unit: 990, currency: 'usd' },
})
await service.track({
userId: 'user-2',
feature: 'auth',
action: 'user_signed_up',
status: 'succeeded',
})
expect(capture).toHaveBeenNthCalledWith(1, {
distinctId: 'user-1',
event: 'payment_completed',
properties: {
surface: 'server',
feature: 'billing',
status: 'succeeded',
source: 'stripe.webhook',
amount_minor_unit: 990,
currency: 'usd',
},
})
expect(capture).toHaveBeenNthCalledWith(2, {
distinctId: 'user-2',
event: 'signup_completed',
properties: {
surface: 'server',
feature: 'auth',
status: 'succeeded',
},
})
const rows = await db.select().from(schema.productEvents)
expect(rows).toHaveLength(2)
})
it('does not forward high-volume per-request actions to PostHog', async () => {
const capture = vi.fn(async () => {})
const sink = { capture, shutdown: vi.fn(async () => {}) }
const service = createProductEventService(db, null, sink)
await service.track({
userId: 'user-1',
feature: 'gen_ai_chat',
action: 'completion_succeeded',
status: 'succeeded',
})
await service.track({
userId: 'user-1',
feature: 'billing',
action: 'checkout_started',
status: 'started',
})
expect(capture).not.toHaveBeenCalled()
const rows = await db.select().from(schema.productEvents)
expect(rows).toHaveLength(2)
})
it('does not forward to PostHog when the DB write fails', async () => {
// ROOT CAUSE:
//
// track() swallows DB insert errors to protect the caller, but the
// PostHog forwarding block ran unconditionally afterwards — a Postgres
// outage during a Stripe webhook would mint `payment_completed` in
// PostHog with no `product_events` row backing it, breaking the
// "Postgres is the fact of record" invariant and later reconciliation.
// Found by PR #2038 review.
//
// Fixed by gating forwarding on a `persisted` flag set only after the
// insert resolves.
const capture = vi.fn(async () => {})
const sink = { capture, shutdown: vi.fn(async () => {}) }
const service = createProductEventService(db, null, sink)
// Simulate a DB outage by renaming the table out from under the insert.
await db.execute(sql`ALTER TABLE product_events RENAME TO product_events_outage`)
try {
await expect(service.track({
userId: 'user-1',
feature: 'billing',
action: 'payment_completed',
status: 'succeeded',
})).resolves.toBeUndefined()
}
finally {
await db.execute(sql`ALTER TABLE product_events_outage RENAME TO product_events`)
}
expect(capture).not.toHaveBeenCalled()
const rows = await db.select().from(schema.productEvents)
expect(rows).toHaveLength(0)
})
it('persists the product event even when a misbehaving sink throws', async () => {
// ROOT CAUSE:
//
// The PosthogSink contract says implementations swallow transport
// errors, but the forwarding call sits on the Stripe webhook path —
// if a sink ever throws, an unguarded `await` would fail the webhook
// after the fact was already persisted, causing Stripe to retry and
// (before the idempotency guard) double-process the payment.
//
// track() therefore wraps forwarding in its own try/catch: the DB row
// must survive and track() must resolve regardless of sink behavior.
const capture = vi.fn(async () => {
throw new Error('posthog exploded')
})
const sink = { capture, shutdown: vi.fn(async () => {}) }
const service = createProductEventService(db, null, sink)
await expect(service.track({
userId: 'user-1',
feature: 'billing',
action: 'payment_completed',
status: 'succeeded',
})).resolves.toBeUndefined()
const rows = await db.select().from(schema.productEvents)
expect(rows).toHaveLength(1)
expect(rows[0]).toMatchObject({ action: 'payment_completed', status: 'succeeded' })
})
})

View file

@ -1,6 +1,7 @@
import type { Database } from '../../libs/db'
import type { ProductMetrics } from '../../otel'
import type { ProductEventMetadata } from '../../schemas/product-events'
import type { PosthogSink } from '../adapters/posthog'
import { useLogger } from '@guiiai/logg'
import { and, asc, count, gte, lt, sql } from 'drizzle-orm'
@ -75,6 +76,24 @@ export interface ProductEventAggregateRow {
distinctUsers: number
}
/**
* Server-side actions worth a PostHog copy, mapped to the event name the
* client-side funnels expect. Only business facts that terminate or anchor
* a funnel are forwarded per-request LLM/TTS volume stays in Postgres and
* Grafana where it belongs (see `docs/ai-context/metrics-ownership.md`).
*
* `user_signed_up` maps to `signup_completed` because that is the canonical
* activation-funnel step name the browser surfaces emit; the server copy
* covers OAuth signups the auth UI cannot classify client-side.
*/
const POSTHOG_FORWARDED_ACTIONS: Partial<Record<ProductAction, string>> = {
user_signed_up: 'signup_completed',
payment_completed: 'payment_completed',
subscription_started: 'subscription_started',
subscription_renewed: 'subscription_renewed',
subscription_cancelled: 'subscription_cancelled',
}
/**
* Builds bounded Prometheus labels from product event inputs.
*/
@ -113,9 +132,14 @@ function metricLabels(input: ProductEventInput): Record<string, string> {
* Returns:
* - Best-effort event writer plus a DB aggregation helper for analytics jobs.
*/
export function createProductEventService(db: Database, metrics?: ProductMetrics | null) {
export function createProductEventService(db: Database, metrics?: ProductMetrics | null, posthog?: PosthogSink | null) {
return {
async track(input: ProductEventInput): Promise<void> {
// Postgres is the fact of record, so forwarding is gated both ways:
// the DB write comes first (a PostHog outage can't lose the row) and
// forwarding only runs when the row actually landed (a DB outage
// can't mint PostHog events with no DB backing).
let persisted = false
try {
await db.insert(schema.productEvents).values({
userId: input.userId,
@ -129,6 +153,7 @@ export function createProductEventService(db: Database, metrics?: ProductMetrics
metadata: input.metadata,
createdAt: input.createdAt,
})
persisted = true
metrics?.events.add(1, metricLabels(input))
}
@ -140,6 +165,32 @@ export function createProductEventService(db: Database, metrics?: ProductMetrics
status: input.status,
}).warn('Failed to write product event; swallowing to protect caller')
}
// PostHog copy so browser funnels (identified by the same Better Auth
// user id) get their server-side terminator events.
const forwardedEvent = POSTHOG_FORWARDED_ACTIONS[input.action]
if (persisted && posthog && forwardedEvent) {
try {
await posthog.capture({
distinctId: input.userId,
event: forwardedEvent,
properties: {
surface: 'server',
feature: input.feature,
status: input.status,
...(input.source && { source: input.source }),
...(input.reason && { reason: input.reason }),
...input.metadata,
},
})
}
catch (err) {
// The sink contract already swallows transport errors; this guard
// is the last line so a misbehaving sink can never fail the
// webhook/auth flow that produced the business fact.
logger.withError(err).withFields({ action: input.action }).warn('PostHog forwarding threw; product event already persisted')
}
}
},
async countDistinctUsersByFeature(input: ProductEventAggregateInput): Promise<ProductEventAggregateRow[]> {

View file

@ -7,14 +7,14 @@ import semver from 'semver'
import { useElectronAutoUpdater, useElectronEventaInvoke } from '@proj-airi/electron-vueuse'
import { AboutContent, BugReportDialog, createBugReportPageContext, MarkdownRenderer } from '@proj-airi/stage-ui/components'
import { useBreakpoints } from '@proj-airi/stage-ui/composables'
import { useAnalytics, useBreakpoints } from '@proj-airi/stage-ui/composables'
import { useSharedAnalyticsStore } from '@proj-airi/stage-ui/stores/analytics'
import { Button, ContainerError, DoubleCheckButton, FieldSelect, Progress } from '@proj-airi/ui'
import { useClipboard } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { DialogContent, DialogDescription, DialogOverlay, DialogPortal, DialogRoot, DialogTitle } from 'reka-ui'
import { DrawerContent, DrawerDescription, DrawerHandle, DrawerOverlay, DrawerPortal, DrawerRoot, DrawerTitle } from 'vaul-vue'
import { computed, onMounted, ref } from 'vue'
import { computed, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { electronGetUpdaterPreferences, electronSetUpdaterPreferences } from '../../shared/eventa'
@ -32,6 +32,13 @@ const {
quitAndInstall,
} = useElectronAutoUpdater()
const {
trackUpdateCheckClicked,
trackUpdateDownloaded,
trackUpdateInstallClicked,
trackSettingsChanged,
} = useAnalytics()
const isDisabled = computed(() => updateState.value.status === 'disabled')
const isLatestVersion = computed(() => {
return updateState.value.status === 'not-available' && !isDisabled.value
@ -119,6 +126,23 @@ const isDowngradeUpdate = computed(() => {
const getUpdaterPreferences = useElectronEventaInvoke(electronGetUpdaterPreferences)
const setUpdaterPreferences = useElectronEventaInvoke(electronSetUpdaterPreferences)
function handleCheckForUpdates() {
trackUpdateCheckClicked({ channel: selectedUpdateChannel.value })
return checkForUpdates()
}
function handleQuitAndInstall() {
trackUpdateInstallClicked({ channel: selectedUpdateChannel.value, version: updateState.value.info?.version })
quitAndInstall()
}
// Fires on the state transition (not the download click) so the event means
// "an update binary actually landed on this machine".
watch(() => updateState.value.status, (status) => {
if (status === 'downloaded')
trackUpdateDownloaded({ channel: selectedUpdateChannel.value, version: updateState.value.info?.version })
})
function handleDownloadClick() {
if (updateState.value.info?.releaseNotes)
showChangelog.value = true
@ -181,9 +205,16 @@ async function setUpdateChannelPreference(channel: UpdateChannelOption) {
isUpdateChannelUpdating.value = true
try {
const previousChannel = selectedUpdateChannel.value
const nextChannel = channel === 'auto' ? undefined : channel as ElectronUpdaterChannel
const preferences = await setUpdaterPreferences({ channel: nextChannel })
selectedUpdateChannel.value = preferences?.channel ?? 'auto'
trackSettingsChanged({
setting_name: 'update_channel',
previous_value: previousChannel,
new_value: selectedUpdateChannel.value,
source: 'settings',
})
await checkForUpdates()
}
finally {
@ -325,7 +356,7 @@ onMounted(() => {
<div>
<DoubleCheckButton
variant="primary"
@confirm="quitAndInstall()"
@confirm="handleQuitAndInstall()"
>
{{ restartButtonLabel }}
<template #confirm>
@ -366,7 +397,7 @@ onMounted(() => {
: isError
? t('tamagotchi.stage.about.update.actions.retry-check')
: t('tamagotchi.stage.about.update.actions.check-for-updates')"
@click="checkForUpdates()"
@click="handleCheckForUpdates()"
/>
</div>
</div>

View file

@ -3,6 +3,7 @@ import type { ServerChannelQrPayload } from '@proj-airi/stage-shared/server-chan
import { errorMessageFrom } from '@moeru/std'
import { useElectronEventaInvoke } from '@proj-airi/electron-vueuse'
import { useAnalytics } from '@proj-airi/stage-ui/composables'
import { Button, Callout, Collapsible } from '@proj-airi/ui'
import { storeToRefs } from 'pinia'
import { renderSVG } from 'uqr'
@ -13,7 +14,14 @@ import { electronGetServerChannelQrPayload } from '../../../../shared/eventa'
import { useServerChannelSettingsStore } from '../../../stores/settings/server-channel'
const { t } = useI18n()
const { trackDevicePairingQrShown } = useAnalytics()
const getServerChannelQrPayload = useElectronEventaInvoke(electronGetServerChannelQrPayload)
function handleToggleExpanded(visible: boolean, setVisible: (value: boolean) => void) {
setVisible(visible)
if (visible)
trackDevicePairingQrShown()
}
const { authToken, hostname, tlsConfig } = storeToRefs(useServerChannelSettingsStore())
const loading = shallowRef(false)
@ -82,7 +90,7 @@ watch([hostname, tlsConfig, authToken], () => {
:class="[
'w-full flex items-center justify-between gap-3 rounded-xl text-left outline-none transition-all duration-250 ease-in-out',
]"
@click="slotProps.setVisible(!slotProps.visible)"
@click="handleToggleExpanded(!slotProps.visible, slotProps.setVisible)"
>
<div :class="['min-w-0 flex flex-col gap-1']">
<div :class="['text-sm font-medium text-neutral-900 dark:text-neutral-100']">

View file

@ -2,18 +2,21 @@
import type { DataSettingsStatusEmits } from '@proj-airi/stage-pages/pages/settings/data/status'
import { createDataSettingsStatusHelpers } from '@proj-airi/stage-pages/pages/settings/data/status'
import { useAnalytics } from '@proj-airi/stage-ui/composables'
import { useDataMaintenance } from '@proj-airi/stage-ui/composables/use-data-maintenance'
import { DoubleCheckButton } from '@proj-airi/ui'
import { useI18n } from 'vue-i18n'
const emit = defineEmits<DataSettingsStatusEmits>()
const { t } = useI18n()
const { trackDataAction } = useAnalytics()
const { resetDesktopApplicationState } = useDataMaintenance()
const { emitStatus, handleActionError } = createDataSettingsStatusHelpers(emit)
async function resetDesktopState() {
try {
await resetDesktopApplicationState()
trackDataAction({ action: 'desktop_state_reset' })
emitStatus(t('settings.pages.data.status.desktop_reset'))
}
catch (error) {

View file

@ -14,6 +14,7 @@ import type {
import { errorMessageFrom } from '@moeru/std'
import { useElectronEventaContext, useElectronEventaInvoke } from '@proj-airi/electron-vueuse'
import { ModelSettingsPanel } from '@proj-airi/stage-ui/components/scenarios/settings/model-settings'
import { useAnalytics } from '@proj-airi/stage-ui/composables'
import { useSettings } from '@proj-airi/stage-ui/stores/settings'
import { Button, Callout } from '@proj-airi/ui'
import { storeToRefs } from 'pinia'
@ -272,9 +273,12 @@ async function syncGodotSceneInput(model: DisplayModel) {
}
}
const { trackFeatureUsed } = useAnalytics()
async function handleGodotStageToggle() {
switchingGodotStage.value = true
godotStageError.value = undefined
const enablingGodot = !usesGodotStage.value
try {
if (usesGodotStage.value) {
@ -291,6 +295,12 @@ async function handleGodotStageToggle() {
await refreshGodotStageStatus()
}
finally {
trackFeatureUsed({
feature_name: enablingGodot ? 'godot_stage_enabled' : 'godot_stage_disabled',
business_domain: 'stage_rendering',
entry: 'settings',
success: godotStageError.value === undefined,
})
switchingGodotStage.value = false
}
}

View file

@ -8,6 +8,7 @@ import type { ServerForm } from './mcp-config'
import { errorMessageFrom } from '@moeru/std'
import { useElectronEventaInvoke } from '@proj-airi/electron-vueuse'
import { useAnalytics } from '@proj-airi/stage-ui/composables'
import {
Button,
Callout,
@ -41,6 +42,7 @@ import {
} from './mcp-config'
const { t } = useI18n()
const { trackMcpServerAdded, trackMcpServerRemoved, trackMcpConnectionTestRun } = useAnalytics()
const tn = (key: string, params?: Record<string, unknown>) => t(`settings.pages.modules.mcp-server.${key}`, params ?? {})
const invokeOpenConfigFile = useElectronEventaInvoke(electronMcpOpenConfigFile)
@ -213,14 +215,17 @@ function formatJsonDraft() {
function addServer() {
const server = createServerForm()
servers.value.push(server)
trackMcpServerAdded()
if (!testRowId.value)
testRowId.value = server.rowId
}
function removeServer(rowId: string) {
const i = servers.value.findIndex(s => s.rowId === rowId)
if (i >= 0)
if (i >= 0) {
servers.value.splice(i, 1)
trackMcpServerRemoved()
}
savedIds.value.delete(rowId)
expandedIds.value.delete(rowId)
if (testRowId.value === rowId)
@ -320,6 +325,7 @@ async function runConnectionTest() {
testResult.value = { ok: false, error: errorMessageFrom(e) ?? 'Unknown error', durationMs: 0 }
}
finally {
trackMcpConnectionTestRun({ success: testResult.value?.ok === true })
testRunning.value = false
}
}

View file

@ -3,6 +3,7 @@ import type { ShortcutAccelerator, ShortcutFailureReason } from '@proj-airi/stag
import { useElectronEventaInvoke } from '@proj-airi/electron-vueuse'
import { formatAccelerator, ShortcutFailureReasons } from '@proj-airi/stage-shared/global-shortcut'
import { useAnalytics } from '@proj-airi/stage-ui/composables'
import { Button } from '@proj-airi/ui'
import { isMacOS } from 'std-env'
import { computed, onMounted, shallowRef } from 'vue'
@ -17,6 +18,7 @@ import { isSafeSpotlightAccelerator } from '../../../../shared/spotlight-shortcu
const getShortcut = useElectronEventaInvoke(electronSpotlightShortcutGet)
const setShortcut = useElectronEventaInvoke(electronSpotlightShortcutSet)
const { trackSettingsChanged } = useAnalytics()
const { t } = useI18n()
const tt = (key: string) => t(`tamagotchi.settings.pages.system.window-shortcuts.${key}`)
@ -64,6 +66,13 @@ async function saveShortcut(next: ShortcutAccelerator | null) {
return
}
accelerator.value = result.actualAccelerator ?? next ?? accelerator.value
// Value is intentionally coarse (customized / reset) the exact key
// combo is a fingerprinting-adjacent detail no dashboard needs.
trackSettingsChanged({
setting_name: 'spotlight_shortcut',
new_value: next ? 'customized' : 'reset_to_default',
source: 'settings',
})
}
catch {
toast.error(tt('errors.failed'))

View file

@ -1,6 +1,7 @@
<script setup lang="ts">
import { errorMessageFrom } from '@moeru/std'
import { useElectronEventaInvoke } from '@proj-airi/electron-vueuse'
import { useAnalytics } from '@proj-airi/stage-ui/composables'
import { useWindowFocus } from '@vueuse/core'
import { shallowRef, useTemplateRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
@ -17,6 +18,7 @@ const sending = shallowRef(false)
const inputRef = useTemplateRef<HTMLInputElement>('inputRef')
const chatSyncStore = useChatSyncStore()
const { trackSpotlightUsed } = useAnalytics()
const hideSpotlightWindow = useElectronEventaInvoke(electronSpotlightHide)
const showResultNotification = useElectronEventaInvoke(electronSpotlightShowResultNotification)
const { t } = useI18n()
@ -39,6 +41,7 @@ async function handleSend() {
messageInput.value = ''
sending.value = true
trackSpotlightUsed()
try {
await hideSpotlightWindow()

View file

@ -2,6 +2,7 @@
import type { WidgetsIframeRequestPayload, WidgetsIframeRequestResultPayload, WidgetSnapshot, WidgetWindowSize } from '../../shared/eventa'
import { useElectronEventaContext, useElectronEventaInvoke } from '@proj-airi/electron-vueuse'
import { useAnalytics } from '@proj-airi/stage-ui/composables'
import { computed, defineAsyncComponent, defineComponent, h, onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute } from 'vue-router'
@ -100,6 +101,8 @@ async function requestSnapshot(id: string) {
}
}
const { trackWidgetOpened } = useAnalytics()
watch(widgetId, (id) => {
clearTtl()
widget.value = null
@ -107,6 +110,7 @@ watch(widgetId, (id) => {
loading.value = false
if (!id)
return
trackWidgetOpened({ widget_id: id })
requestSnapshot(id)
}, { immediate: true })

View file

@ -1,5 +1,6 @@
<script setup lang="ts">
import { errorMessageFrom } from '@moeru/std'
import { useAnalytics } from '@proj-airi/stage-ui/composables'
import { applyOIDCTokens, fetchSession, triggerSignIn } from '@proj-airi/stage-ui/libs/auth'
import { consumeFlowState, exchangeCodeForTokens } from '@proj-airi/stage-ui/libs/auth-oidc'
import { Button } from '@proj-airi/ui'
@ -9,6 +10,7 @@ import { useRouter } from 'vue-router'
const router = useRouter()
const { t } = useI18n()
const { trackOauthCallbackFailed } = useAnalytics()
const error = ref<string | null>(null)
onMounted(async () => {
@ -18,17 +20,20 @@ onMounted(async () => {
const errorParam = url.searchParams.get('error')
if (errorParam) {
trackOauthCallbackFailed({ stage: 'provider_error' })
error.value = url.searchParams.get('error_description') ?? errorParam
return
}
if (!code || !state) {
trackOauthCallbackFailed({ stage: 'missing_code_or_state' })
error.value = t('server.auth.webCallback.message.missingCodeOrState')
return
}
const persisted = consumeFlowState()
if (!persisted) {
trackOauthCallbackFailed({ stage: 'missing_flow_state' })
error.value = t('server.auth.webCallback.message.missingFlowState')
return
}
@ -40,6 +45,7 @@ onMounted(async () => {
router.replace('/')
}
catch (err) {
trackOauthCallbackFailed({ stage: 'token_exchange_failed' })
error.value = errorMessageFrom(err) ?? t('server.auth.webCallback.message.tokenExchangeFailed')
}
})

View file

@ -27,7 +27,7 @@ const emit = defineEmits<{
}>()
const characterStore = useCharacterStore()
const { trackCharacterCreated } = useAnalytics()
const { trackCharacterCreated, trackCharacterUpdated } = useAnalytics()
// Form State
const form = reactive({
@ -154,6 +154,7 @@ async function handleSubmit() {
version: form.version,
coverUrl: form.coverUrl,
})
trackCharacterUpdated({ character_id: props.character.id })
// Capabilities/I18n update not supported in simple UpdateCharacterSchema yet?
// Checking types/character.ts: UpdateCharacterSchema only has version, coverUrl, characterId.
// So deep update is not supported by the simple endpoint yet?

View file

@ -1,6 +1,7 @@
<script setup lang="ts">
import type { Character } from '@proj-airi/stage-ui/types/character'
import { useAnalytics } from '@proj-airi/stage-ui/composables'
import { useCharacterStore } from '@proj-airi/stage-ui/stores/characters'
import { Button, FieldInput } from '@proj-airi/ui'
import { storeToRefs } from 'pinia'
@ -9,6 +10,7 @@ import { computed, onMounted, ref } from 'vue'
import CharacterDialog from './components/CharacterDialog.vue'
import CharacterItem from './components/CharacterItem.vue'
const { trackCharacterDeleted } = useAnalytics()
const characterStore = useCharacterStore()
const { characters } = storeToRefs(characterStore)
@ -45,7 +47,9 @@ function handleDelete(id: string) {
// TODO: Remove this
// eslint-disable-next-line no-alert
if (confirm('Are you sure you want to delete this character?')) {
characterStore.remove(id).catch(console.error)
characterStore.remove(id)
.then(() => trackCharacterDeleted({ character_id: id }))
.catch(console.error)
}
}

View file

@ -14,6 +14,7 @@ import { routes } from 'vue-router/auto-routes'
import App from './App.vue'
import { initAuthAnalytics } from './modules/analytics'
import { AUTH_UI_ROUTER_BASE_PATH } from './modules/auth-ui-base'
import { i18n } from './modules/i18n'
@ -23,6 +24,8 @@ import 'vue-sonner/style.css'
import './styles/main.css'
import 'uno.css'
initAuthAnalytics()
const pinia = createPinia()
// TODO: vite-plugin-vue-layouts is long deprecated, replace with another layout solution

View file

@ -0,0 +1,167 @@
/**
* PostHog product analytics for the auth-only SPA (`apps/ui-server-auth`).
*
* This surface is the top of the activation funnel: sign-up, sign-in, email
* verification, password recovery. Events captured here are the funnel
* entry that the in-app surfaces (`signup_completed → onboarding_started →
* first message`) join against, keyed by the Better Auth user id passed to
* {@link identifyAuthUser} the same id the server uses as `distinctId`
* for its own events, so the person profiles merge.
*
* Unlike the stage apps there is no in-app analytics consent toggle here
* (the user isn't signed in yet, so there's no settings store to read).
* Capture posture matches the docs site: enabled in analytics-enabled
* builds (`VITE_ENABLE_POSTHOG`), disclosed via the privacy policy linked
* on the sign-in page.
*/
import type { OauthCallbackFailureStage } from '@proj-airi/stage-ui/composables'
import posthog from 'posthog-js'
import {
DEFAULT_POSTHOG_CONFIG,
POSTHOG_ENABLED,
POSTHOG_PROJECT_KEY,
} from '../../../../posthog.config'
/** Login/signup credential kinds shown on the sign-in page. */
export type AuthMethod = 'email' | 'github' | 'google'
let initialized = false
/**
* Initialize PostHog for the auth surface. Call once from `main.ts` before
* mount; later calls are no-ops. Returns whether capture is active so
* callers can skip building event payloads in analytics-disabled builds.
*/
export function initAuthAnalytics(): boolean {
if (!POSTHOG_ENABLED)
return false
if (initialized)
return true
posthog.init(POSTHOG_PROJECT_KEY, { ...DEFAULT_POSTHOG_CONFIG })
// Same single-project setup as the stage apps: the `surface` super
// property is how auth traffic is told apart in shared dashboards.
posthog.register({ surface: 'auth' })
initialized = true
return true
}
/**
* Merge this browser's anonymous events with the Better Auth user person.
* `userId` must be the Better Auth `user.id` the same value the server
* uses as `distinctId` (see `apps/server` product events forwarding).
*/
export function identifyAuthUser(userId: string): void {
if (!initialized)
return
posthog.identify(userId)
}
interface CaptureOptions {
/**
* Set when navigation immediately follows the capture call
* (`window.location.href = ...`). The batched queue would race the
* unload and drop the event; sendBeacon survives it.
*/
beforeNavigation?: boolean
}
function capture(event: string, properties: Record<string, unknown>, options?: CaptureOptions): void {
if (!initialized)
return
posthog.capture(
event,
properties,
options?.beforeNavigation ? { send_instantly: true, transport: 'sendBeacon' } : undefined,
)
}
/** Activation funnel step 1 — the account now exists (email flow). */
export function trackSignupCompleted(properties: { source: AuthMethod, requires_verification: boolean }): void {
capture('signup_completed', properties, { beforeNavigation: !properties.requires_verification })
}
/**
* OAuth flows leave the page before their outcome is knowable, so the
* client can only record the attempt; completion shows up as the
* identified session on the callback landing.
*/
export function trackLoginStarted(properties: { method: AuthMethod }): void {
capture('login_started', properties, { beforeNavigation: true })
}
/** Credential sign-in succeeded; OIDC continuation navigation follows. */
export function trackLoginSucceeded(properties: { method: AuthMethod }): void {
capture('login_succeeded', properties, { beforeNavigation: true })
}
/**
* Sign-in attempt failed. No error detail on purpose auth error messages
* can embed the email address, and the count per method is what the funnel
* needs.
*/
export function trackLoginFailed(properties: { method: AuthMethod }): void {
capture('login_failed', properties)
}
/** Verification link landing with `?verified=true`. */
export function trackEmailVerificationCompleted(): void {
capture('email_verification_completed', {})
}
/** Verification link landing with `?error=...`. */
export function trackEmailVerificationFailed(): void {
capture('email_verification_failed', {})
}
export function trackPasswordResetRequested(): void {
capture('password_reset_requested', {})
}
export function trackPasswordResetCompleted(): void {
capture('password_reset_completed', {})
}
export function trackPasswordChanged(): void {
capture('password_changed', {})
}
/**
* Link handed off to the provider's consent page. Completion is not
* client-observable (it lands back via a full-page OAuth redirect), so the
* funnel pairs this with the refreshed linked-accounts state server-side.
*/
export function trackOauthProviderLinkStarted(properties: { provider: string }): void {
capture('oauth_provider_link_started', properties, { beforeNavigation: true })
}
export function trackOauthProviderUnlinked(properties: { provider: string }): void {
capture('oauth_provider_unlinked', properties)
}
/**
* Deletion-confirmed landing page reached (`delete-account.vue`). The
* deletion request itself is raised from the stage apps' account settings.
*/
export function trackAccountDeletionCompleted(): void {
capture('account_deletion_completed', {})
}
export function trackSignedOut(): void {
capture('signed_out', {})
}
/**
* Electron OIDC relay handoff failed. `stage` distinguishes a malformed
* callback (`parse`) from an unreachable local app (`relay_unreachable`);
* the full cross-surface vocabulary lives in stage-ui's
* `OauthCallbackFailureStage` so the two emitters share one schema.
*/
export function trackOauthCallbackFailed(properties: { stage: Extract<OauthCallbackFailureStage, 'parse' | 'relay_unreachable'> }): void {
capture('oauth_callback_failed', properties)
}

View file

@ -1,8 +1,10 @@
<script setup lang="ts">
import { computed } from 'vue'
import { computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute } from 'vue-router'
import { trackAccountDeletionCompleted } from '../modules/analytics'
// NOTICE:
// This page is a SUCCESS landing better-auth's `/api/auth/delete-user/callback`
// performs the actual deletion server-side, then redirects here via the
@ -28,6 +30,14 @@ const errorMessage = computed(() => {
const value = route.query.error
return typeof value === 'string' ? value : null
})
onMounted(() => {
// Strongest churn fact the client can observe: better-auth only
// redirects here after the deletion callback already ran server-side,
// so rendering the success state means the account is gone.
if (!errorMessage.value)
trackAccountDeletionCompleted()
})
</script>
<template>

View file

@ -4,6 +4,7 @@ import { onMounted, shallowRef } from 'vue'
import { useI18n } from 'vue-i18n'
import { parseElectronCallbackQuery } from '../composables/electron-callback.shared'
import { trackOauthCallbackFailed } from '../modules/analytics'
import { getServerAuthBootstrapContext } from '../modules/server-auth-context'
type CallbackStatus = 'loading' | 'success' | 'fallback' | 'error'
@ -55,6 +56,7 @@ async function runRelayFlow() {
const parsed = parseElectronCallbackQuery(query)
if (parsed.status === 'error') {
trackOauthCallbackFailed({ stage: 'parse' })
setViewModel({
description: t('server.auth.electronCallback.message.invalidResponse'),
detail: parsed.message,
@ -98,6 +100,7 @@ async function runRelayFlow() {
}, 1200)
}
catch {
trackOauthCallbackFailed({ stage: 'relay_unreachable' })
setViewModel({
description: t('server.auth.electronCallback.message.loopbackUnreachable'),
detail: t('server.auth.electronCallback.message.tryOpenDirectly'),

View file

@ -4,6 +4,7 @@ import { Button, FieldInput } from '@proj-airi/ui'
import { reactive, shallowRef } from 'vue'
import { useI18n } from 'vue-i18n'
import { trackPasswordResetRequested } from '../modules/analytics'
import { buildCurrentOriginAuthUiUrl } from '../modules/auth-ui-base'
import { describeAuthError, requestPasswordReset } from '../modules/email-password'
import { getServerAuthBootstrapContext } from '../modules/server-auth-context'
@ -36,6 +37,7 @@ async function handleSubmit(event: Event) {
email: form.email.trim(),
redirectTo: resetRedirect,
})
trackPasswordResetRequested()
submitted.value = true
}
catch (error) {

View file

@ -9,6 +9,14 @@ import { computed, onMounted, reactive, shallowRef } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import {
identifyAuthUser,
trackOauthProviderLinkStarted,
trackOauthProviderUnlinked,
trackPasswordChanged,
trackPasswordResetRequested,
trackSignedOut,
} from '../modules/analytics'
import { getAuthClient } from '../modules/auth-client'
import { requestPasswordReset } from '../modules/email-password'
import {
@ -98,6 +106,8 @@ const {
unlinked: provider => t('server.auth.profile.linkedAccounts.message.unlinked', { provider }),
linkStarted: provider => t('server.auth.profile.linkedAccounts.message.linkStarted', { provider }),
},
onUnlinked: providerId => trackOauthProviderUnlinked({ provider: providerId }),
onLinkStarted: providerId => trackOauthProviderLinkStarted({ provider: providerId }),
})
const nameDirty = computed(() => {
@ -138,6 +148,9 @@ onMounted(async () => {
// call needed here.
user.value = result.user
profileForm.name = result.user.name
// Merge this browser's anonymous funnel events (sign-in page views,
// login_started, ) into the Better Auth user person.
identifyAuthUser(result.user.id)
}
catch (error) {
profileError.value = describeProfileError(error) || t('server.auth.profile.error.loadFailed')
@ -200,6 +213,7 @@ async function handleChangePassword(event: Event) {
passwordForm.next = ''
passwordForm.confirm = ''
passwordSuccess.value = t('server.auth.profile.message.passwordChanged')
trackPasswordChanged()
}
catch (error) {
passwordError.value = describeProfileError(error) || t('server.auth.profile.error.changePasswordFailed')
@ -237,6 +251,7 @@ async function handleSendSetPasswordLink() {
redirectTo: new URL('/auth/reset-password', apiServerUrl).toString(),
})
setPasswordSuccess.value = t('server.auth.profile.password.setLinkSent', { email: user.value.email })
trackPasswordResetRequested()
}
catch (error) {
setPasswordError.value = describeProfileError(error)
@ -256,6 +271,7 @@ async function handleSignOut() {
try {
await signOut({ apiServerUrl })
trackSignedOut()
await router.replace('/sign-in')
}
catch (error) {

View file

@ -5,6 +5,7 @@ import { computed, reactive, shallowRef } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
import { trackPasswordResetCompleted } from '../modules/analytics'
import { describeAuthError, resetPasswordWithToken } from '../modules/email-password'
import { getServerAuthBootstrapContext } from '../modules/server-auth-context'
@ -52,6 +53,7 @@ async function handleSubmit(event: Event) {
newPassword: form.password,
token: token.value,
})
trackPasswordResetCompleted()
completed.value = true
}
catch (error) {

View file

@ -8,6 +8,12 @@ import { computed, reactive, shallowRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
import {
trackLoginFailed,
trackLoginStarted,
trackLoginSucceeded,
trackSignupCompleted,
} from '../modules/analytics'
import { buildCurrentOriginAuthUiUrl } from '../modules/auth-ui-base'
import {
checkEmail,
@ -127,9 +133,11 @@ async function handleProviderSelect(provider: OAuthProvider) {
callbackURL: effectiveCallbackURL.value,
})
trackLoginStarted({ method: provider })
window.location.href = redirectUrl
}
catch (error) {
trackLoginFailed({ method: provider })
errorMessage.value = describeAuthError(error) || t('server.auth.signIn.error.fallback')
pendingProvider.value = null
}
@ -202,9 +210,11 @@ async function handleEmailSignIn(event: Event) {
// After a successful credential sign-in better-auth has set the session
// cookie. Bounce into the OIDC `/oauth2/authorize` flow (or wherever the
// OIDC client originally pointed) so the upstream stage app gets its tokens.
trackLoginSucceeded({ method: 'email' })
window.location.href = result.redirectURL ?? effectiveCallbackURL.value
}
catch (error) {
trackLoginFailed({ method: 'email' })
errorMessage.value = describeAuthError(error) || t('server.auth.signIn.error.fallback')
}
finally {
@ -237,6 +247,7 @@ async function handleEmailSignUp(event: Event) {
})
if (result.requiresVerification) {
trackSignupCompleted({ source: 'email', requires_verification: true })
await router.push({
path: '/verify-email',
query: {
@ -249,6 +260,7 @@ async function handleEmailSignUp(event: Event) {
// Verification disabled at server config: session is live, fall through
// to the OIDC continuation just like sign-in.
trackSignupCompleted({ source: 'email', requires_verification: false })
window.location.href = effectiveCallbackURL.value
}
catch (error) {

View file

@ -5,6 +5,7 @@ import { computed, onMounted, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute } from 'vue-router'
import { trackEmailVerificationCompleted, trackEmailVerificationFailed } from '../modules/analytics'
import { buildCurrentOriginAuthUiUrl } from '../modules/auth-ui-base'
import { getServerAuthBootstrapContext } from '../modules/server-auth-context'
@ -87,13 +88,16 @@ onMounted(async () => {
// session cookie has been written, then stay put so the user sees the
// success message. The pending tab does the OIDC continuation.
if (verified.value) {
trackEmailVerificationCompleted()
if (isSupported.value)
post('verified')
return
}
if (error.value)
if (error.value) {
trackEmailVerificationFailed()
return
}
// Pending tab: cover the case where verification already happened before
// this tab subscribed (back-button navigation, page reload, etc.). One

View file

@ -1,7 +1,7 @@
<script setup lang="ts">
import { errorMessageFrom } from '@moeru/std'
import { defaultSignInProviders } from '@proj-airi/stage-ui/components/auth'
import { resolveLinkedAccountOAuthErrorMessageKey, useLinkedAccounts } from '@proj-airi/stage-ui/composables'
import { resolveLinkedAccountOAuthErrorMessageKey, useAnalytics, useLinkedAccounts } from '@proj-airi/stage-ui/composables'
import { authClient } from '@proj-airi/stage-ui/libs/auth'
import { SERVER_URL } from '@proj-airi/stage-ui/libs/server'
import { useAuthStore } from '@proj-airi/stage-ui/stores/auth'
@ -22,6 +22,13 @@ const emit = defineEmits<{
const { t } = useI18n()
const route = useRoute()
const router = useRouter()
const {
trackAccountDeletionRequested,
trackOauthProviderLinkStarted,
trackOauthProviderUnlinked,
trackPasswordChanged,
trackPasswordResetRequested,
} = useAnalytics()
const authStore = useAuthStore()
const { isAuthenticated, user, credits } = storeToRefs(authStore)
@ -157,6 +164,8 @@ const {
unlinked: provider => t('settings.pages.account.connections.message.unlinked', { provider }),
linkStarted: provider => t('settings.pages.account.connections.message.linkStarted', { provider }),
},
onUnlinked: providerId => trackOauthProviderUnlinked({ provider: providerId }),
onLinkStarted: providerId => trackOauthProviderLinkStarted({ provider: providerId }),
})
watch(
@ -279,6 +288,7 @@ async function handleChangePassword(event: Event) {
passwordForm.next = ''
passwordForm.confirm = ''
passwordSuccess.value = t('settings.pages.account.security.message.changed')
trackPasswordChanged()
}
catch (error) {
passwordError.value = errorMessageFrom(error) ?? t('settings.pages.account.security.error.fallback')
@ -316,6 +326,7 @@ async function handleSendSetPasswordLink() {
if (error)
throw new Error(error.message ?? 'requestPasswordReset failed')
setPasswordSuccess.value = t('settings.pages.account.security.message.setLinkSent', { email })
trackPasswordResetRequested()
}
catch (error) {
setPasswordError.value = errorMessageFrom(error) ?? t('settings.pages.account.security.error.setLinkFailed')
@ -390,6 +401,7 @@ async function handleConfirmDelete(event: Event) {
deleteSent.value = true
deleteDialogOpen.value = false
trackAccountDeletionRequested()
}
catch (error) {
deleteError.value = errorMessageFrom(error) ?? t('settings.pages.account.danger.deleteAccount.error.fallback')

View file

@ -5,6 +5,7 @@ import type { AiriExtension } from '@proj-airi/stage-ui/stores/modules/airi-card
import kebabcase from '@stdlib/string-base-kebabcase'
import { isCustomProvidersDisabled } from '@proj-airi/stage-shared'
import { useAnalytics } from '@proj-airi/stage-ui/composables'
import { DEFAULT_ARTISTRY_WIDGET_INSTRUCTION } from '@proj-airi/stage-ui/constants/prompts/artistry-instruction'
import { useDisplayModelsStore } from '@proj-airi/stage-ui/stores/display-models'
import { useAiriCardStore } from '@proj-airi/stage-ui/stores/modules/airi-card'
@ -58,6 +59,7 @@ const emit = defineEmits<{
const modelValue = defineModel<boolean>()
const { t } = useI18n()
const { trackCardEdited } = useAnalytics()
const cardStore = useAiriCardStore()
const consciousnessStore = useConsciousnessStore()
const visionStore = useVisionStore()
@ -417,10 +419,11 @@ function saveCard(card: Card): boolean {
if (isEditMode.value && props.cardId) {
// Edit mode: update existing card
cardStore.updateCard(props.cardId, cardWithModules)
trackCardEdited({ card_id: props.cardId })
}
else {
// Create mode: add new card
cardStore.addCard(cardWithModules)
cardStore.addCard(cardWithModules, 'scratch')
}
modelValue.value = false // Close this

View file

@ -3,6 +3,7 @@ import type { AiriCard } from '@proj-airi/stage-ui/stores/modules/airi-card'
import DOMPurify from 'dompurify'
import { useAnalytics } from '@proj-airi/stage-ui/composables'
import { useDownload } from '@proj-airi/stage-ui/composables/download'
import { exportAiriCardPackage } from '@proj-airi/stage-ui/services/airi-card-import-export'
import { useBackgroundStore } from '@proj-airi/stage-ui/stores/background'
@ -38,6 +39,7 @@ const emit = defineEmits<{
}>()
const { t } = useI18n()
const { trackSceneBackgroundSet } = useAnalytics()
const cardStore = useAiriCardStore()
const consciousnessStore = useConsciousnessStore()
const speechStore = useSpeechStore()
@ -182,6 +184,7 @@ const activeBackgroundId = computed({
...selectedCard.value,
extensions: extension,
})
trackSceneBackgroundSet({ source: 'card_gallery', cleared: val === 'none' })
},
})

View file

@ -59,7 +59,7 @@ watch(inputFiles, async (newFiles) => {
return
try {
addCard(await importAiriCardPackage({ file, displayModelsStore }))
addCard(await importAiriCardPackage({ file, displayModelsStore }), 'import')
toast(t('settings.pages.card.imported'))
}
catch (error) {

View file

@ -1,6 +1,7 @@
<script setup lang="ts">
import type { DataSettingsStatusEmits } from '../status'
import { useAnalytics } from '@proj-airi/stage-ui/composables'
import { useDataMaintenance } from '@proj-airi/stage-ui/composables/use-data-maintenance'
import { Button, DoubleCheckButton } from '@proj-airi/ui'
import { shallowRef, useTemplateRef } from 'vue'
@ -10,6 +11,7 @@ import { createDataSettingsStatusHelpers } from '../status'
const emit = defineEmits<DataSettingsStatusEmits>()
const { t } = useI18n()
const { trackDataAction } = useAnalytics()
const importFileInput = useTemplateRef<HTMLInputElement>('importFileInput')
const importError = shallowRef('')
const {
@ -32,6 +34,7 @@ async function triggerExport() {
anchor.download = `airi-chat-sessions-${new Date().toISOString()}.json`
anchor.click()
URL.revokeObjectURL(url)
trackDataAction({ action: 'chats_exported' })
emitStatus(t('settings.pages.data.status.exported'))
}
catch (error) {
@ -42,6 +45,7 @@ async function triggerExport() {
function deleteChats() {
try {
deleteAllChatSessions()
trackDataAction({ action: 'chats_cleared' })
emitStatus(t('settings.pages.data.status.chats_deleted'))
}
catch (error) {
@ -60,6 +64,7 @@ async function handleImport(event: Event) {
const parsed = JSON.parse(raw) as Record<string, unknown>
await importChatSessions(parsed)
importError.value = ''
trackDataAction({ action: 'chats_imported' })
emitStatus(t('settings.pages.data.status.imported'))
}
catch (error) {

View file

@ -1,6 +1,7 @@
<script setup lang="ts">
import type { DataSettingsStatusEmits } from '../status'
import { useAnalytics } from '@proj-airi/stage-ui/composables'
import { useDataMaintenance } from '@proj-airi/stage-ui/composables/use-data-maintenance'
import { DoubleCheckButton } from '@proj-airi/ui'
import { useI18n } from 'vue-i18n'
@ -9,12 +10,14 @@ import { createDataSettingsStatusHelpers } from '../status'
const emit = defineEmits<DataSettingsStatusEmits>()
const { t } = useI18n()
const { trackDataAction } = useAnalytics()
const { deleteAllData, resetProvidersSettings } = useDataMaintenance()
const { emitStatus, handleActionError } = createDataSettingsStatusHelpers(emit)
async function resetProviders() {
try {
await resetProvidersSettings()
trackDataAction({ action: 'provider_settings_reset' })
emitStatus(t('settings.pages.data.status.providers_reset'))
}
catch (error) {
@ -25,6 +28,7 @@ async function resetProviders() {
async function deleteAll() {
try {
await deleteAllData()
trackDataAction({ action: 'app_data_cleared' })
emitStatus(t('settings.pages.data.status.all_deleted'))
}
catch (error) {

View file

@ -1,6 +1,7 @@
<script setup lang="ts">
import type { DataSettingsStatusEmits } from '../status'
import { useAnalytics } from '@proj-airi/stage-ui/composables'
import { useDataMaintenance } from '@proj-airi/stage-ui/composables/use-data-maintenance'
import { DoubleCheckButton } from '@proj-airi/ui'
import { useI18n } from 'vue-i18n'
@ -9,12 +10,14 @@ import { createDataSettingsStatusHelpers } from '../status'
const emit = defineEmits<DataSettingsStatusEmits>()
const { t } = useI18n()
const { trackDataAction } = useAnalytics()
const { deleteAllModels, resetModulesSettings } = useDataMaintenance()
const { emitStatus, handleActionError } = createDataSettingsStatusHelpers(emit)
async function deleteModels() {
try {
await deleteAllModels()
trackDataAction({ action: 'models_cache_cleared' })
emitStatus(t('settings.pages.data.status.models_deleted'))
}
catch (error) {
@ -25,6 +28,7 @@ async function deleteModels() {
function resetModules() {
try {
resetModulesSettings()
trackDataAction({ action: 'modules_settings_reset' })
emitStatus(t('settings.pages.data.status.modules_reset'))
}
catch (error) {

View file

@ -1,11 +1,13 @@
<script setup lang="ts">
import { Section } from '@proj-airi/stage-ui/components'
import { useAnalytics } from '@proj-airi/stage-ui/composables'
import { useAiriCardStore, useBackgroundStore } from '@proj-airi/stage-ui/stores'
import { Button, Callout } from '@proj-airi/ui'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const { trackSceneBackgroundSet } = useAnalytics()
const backgroundStore = useBackgroundStore()
const cardStore = useAiriCardStore()
@ -31,6 +33,7 @@ const activeBackgroundId = computed({
...cardStore.activeCard,
extensions: extension,
})
trackSceneBackgroundSet({ source: 'scene_settings', cleared: val === 'none' })
},
})

View file

@ -127,7 +127,7 @@ function confirmCreate() {
const newId = cardStore.addCard({
...structuredClone(toRaw(current)),
name,
})
}, 'duplicate')
activeCardId.value = newId
cancelCreate()

View file

@ -716,12 +716,6 @@ describe('useAnalytics conversation product events', () => {
preset_type: 'stage_model',
source: 'settings',
})
analytics.trackModelChanged({
from_model: 'gpt-old',
to_model: 'gpt-new',
provider: 'official-provider',
reason: 'manual',
})
analytics.trackProviderSwitched({
from_provider: 'openai-compatible',
to_provider: 'official-provider',
@ -777,14 +771,7 @@ describe('useAnalytics conversation product events', () => {
preset_type: 'stage_model',
source: 'settings',
})
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(7, 'model_changed', {
surface: 'web',
from_model: 'gpt-old',
to_model: 'gpt-new',
provider: 'official-provider',
reason: 'manual',
})
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(8, 'provider_switched', {
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(7, 'provider_switched', {
surface: 'web',
from_provider: 'openai-compatible',
to_provider: 'official-provider',
@ -792,14 +779,14 @@ describe('useAnalytics conversation product events', () => {
to_provider_type: 'official',
reason: 'manual',
})
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(9, 'settings_changed', {
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(8, 'settings_changed', {
surface: 'web',
setting_name: 'analytics_enabled',
previous_value: false,
new_value: true,
source: 'settings',
})
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(10, 'support_contacted', {
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(9, 'support_contacted', {
surface: 'web',
channel: 'discord',
source: 'settings',
@ -853,4 +840,106 @@ describe('useAnalytics conversation product events', () => {
entrypoint: 'community_manual_tag',
})
})
it('emits account lifecycle events shared with the ui-server-auth surface', () => {
const analytics = useAnalytics()
analytics.trackPasswordChanged()
analytics.trackPasswordResetRequested()
analytics.trackOauthProviderLinkStarted({ provider: 'github' })
analytics.trackOauthProviderUnlinked({ provider: 'google' })
analytics.trackAccountDeletionRequested()
analytics.trackOauthCallbackFailed({ stage: 'missing_flow_state' })
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(1, 'password_changed', { surface: 'web' })
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(2, 'password_reset_requested', { surface: 'web' })
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(
3,
'oauth_provider_link_started',
{ surface: 'web', provider: 'github' },
{ send_instantly: true, transport: 'sendBeacon' },
)
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(4, 'oauth_provider_unlinked', {
surface: 'web',
provider: 'google',
})
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(5, 'account_deletion_requested', { surface: 'web' })
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(6, 'oauth_callback_failed', {
surface: 'web',
stage: 'missing_flow_state',
})
})
it('emits AIRI card edit and scene background events', () => {
const analytics = useAnalytics()
analytics.trackCardEdited({ card_id: 'card-1' })
analytics.trackSceneBackgroundSet({ source: 'card_gallery', cleared: false })
analytics.trackSceneBackgroundSet({ source: 'scene_settings', cleared: true })
analytics.trackCharacterUpdated({ character_id: 'character-1' })
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(1, 'card_edited', {
surface: 'web',
card_id: 'card-1',
})
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(2, 'scene_background_set', {
surface: 'web',
source: 'card_gallery',
cleared: false,
})
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(3, 'scene_background_set', {
surface: 'web',
source: 'scene_settings',
cleared: true,
})
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(4, 'character_updated', {
character_id: 'character-1',
})
})
it('emits data maintenance actions as one event with an action discriminator', () => {
const analytics = useAnalytics()
analytics.trackDataAction({ action: 'chats_exported' })
analytics.trackDataAction({ action: 'app_data_cleared' })
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(1, 'data_action', {
surface: 'web',
action: 'chats_exported',
})
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(2, 'data_action', {
surface: 'web',
action: 'app_data_cleared',
})
})
it('emits desktop differentiator events for spotlight, widgets, updater, MCP, and pairing', () => {
analyticsMocks.isStageTamagotchiMock.mockReturnValue(true)
const analytics = useAnalytics()
analytics.trackSpotlightUsed()
analytics.trackWidgetOpened({ widget_id: 'weather' })
analytics.trackUpdateCheckClicked({ channel: 'auto' })
analytics.trackUpdateDownloaded({ channel: 'stable', version: '0.11.0' })
analytics.trackUpdateInstallClicked({ channel: 'stable', version: '0.11.0' })
analytics.trackMcpServerAdded()
analytics.trackMcpServerRemoved()
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(
5,
'update_install_clicked',
{ channel: 'stable', version: '0.11.0' },
{ send_instantly: true, transport: 'sendBeacon' },
)
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(6, 'mcp_server_added')
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(7, 'mcp_server_removed')
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(8, 'mcp_connection_test_run', { success: false })
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(9, 'device_pairing_qr_shown')
})
})

View file

@ -37,6 +37,20 @@ export type ProductAnalyticsEntry = 'app_start' | 'onboarding' | 'settings' | 'c
export type MessageInputMode = 'text' | 'voice'
export type ConversationEventSource = 'new_session' | 'fork' | 'history' | 'share_button' | 'unknown'
/**
* Full stage vocabulary of the cross-surface `oauth_callback_failed` event.
* The web/PKCE stages fire from `pages/auth/callback.vue`; the electron
* relay stages fire from ui-server-auth's `electron-callback.vue`, which
* imports this type so the two emitters can't drift apart silently.
*/
export type OauthCallbackFailureStage
= | 'provider_error'
| 'missing_code_or_state'
| 'missing_flow_state'
| 'token_exchange_failed'
| 'parse'
| 'relay_unreachable'
interface ChatActivationBaseProperties {
provider_mode: ProviderMode
provider_id: string
@ -187,8 +201,10 @@ export function useAnalytics() {
* the regular batched queue would race the redirect and drop the
* event, which breaks the funnel.
*
* The funnel terminator `payment_completed` is emitted server-side from
* the Stripe webhook see `apps/server/src/routes/stripe/index.ts`.
* The funnel terminator `payment_completed` is forwarded to PostHog
* server-side by the product-events service (allowlist in
* `apps/server/src/services/domain/product-events.ts`), keyed by the
* Better Auth user id.
*/
function trackCheckoutStarted(planId: string, properties: { checkout_session_id?: string, price_minor_unit?: number, currency?: string }) {
if (!canCapture())
@ -215,12 +231,71 @@ export function useAnalytics() {
})
}
/** Activation funnel — step 1. */
function trackSignup(method: 'email' | 'google' | 'github' | string) {
/**
* OAuth/OIDC callback landing failed before a session existed. Stage
* values map 1:1 to the guard branches in `pages/auth/callback.vue` so
* the funnel can tell a provider-side denial from a lost PKCE state.
*/
function trackOauthCallbackFailed(properties: {
stage: Extract<OauthCallbackFailureStage, 'provider_error' | 'missing_code_or_state' | 'missing_flow_state' | 'token_exchange_failed'>
}) {
if (!canCapture())
return
posthog.capture('user_signed_up', { method })
posthog.capture('signup_completed', { source: method })
posthog.capture('oauth_callback_failed', {
...properties,
surface: getConversationAnalyticsSurface(),
})
}
// ─── Account lifecycle (same event names as apps/ui-server-auth's
// analytics module — both surfaces feed one PostHog series) ───────────
function trackPasswordChanged() {
if (!canCapture())
return
posthog.capture('password_changed', { surface: getConversationAnalyticsSurface() })
}
function trackPasswordResetRequested() {
if (!canCapture())
return
posthog.capture('password_reset_requested', { surface: getConversationAnalyticsSurface() })
}
function trackOauthProviderLinkStarted(properties: { provider: string }) {
if (!canCapture())
return
// The only caller (`useLinkedAccounts.link`) navigates to the OAuth
// consent page right after this hook — the batched queue would race
// the unload and drop the event, same as `trackCheckoutStarted`.
posthog.capture(
'oauth_provider_link_started',
{
...properties,
surface: getConversationAnalyticsSurface(),
},
{ send_instantly: true, transport: 'sendBeacon' },
)
}
function trackOauthProviderUnlinked(properties: { provider: string }) {
if (!canCapture())
return
posthog.capture('oauth_provider_unlinked', {
...properties,
surface: getConversationAnalyticsSurface(),
})
}
/**
* Deletion email sent (user confirmed in the dialog). The completion
* event lands on ui-server-auth's success page; this one is the churn
* intent signal even when the user never clicks the email link.
*/
function trackAccountDeletionRequested() {
if (!canCapture())
return
posthog.capture('account_deletion_requested', { surface: getConversationAnalyticsSurface() })
}
function trackSignupCompleted(properties: {
@ -255,19 +330,6 @@ export function useAnalytics() {
})
}
/**
* Activation funnel fires the first time a user picks a model in any
* provider settings. De-dup is intentional caller-side (we don't have a
* persistent "first model selected" flag yet); a small number of repeats
* is OK in PostHog funnels because step matching is per-distinctId, not
* per-event.
*/
function trackFirstModelSelected(modelId: string, provider: string) {
if (!canCapture())
return
posthog.capture('first_model_selected', { model_id: modelId, provider })
}
/** Retention driver — character creation is a strong D7 retention predictor. */
function trackCharacterCreated(properties: { character_type: 'built_in' | 'custom', voice_enabled: boolean }) {
if (!canCapture())
@ -312,10 +374,10 @@ export function useAnalytics() {
// ─── LLM round events (client-known fields only) ──────────────────────
// Source-of-truth for HTTP status / token usage / billing stage is the
// server (apps/server/src/routes/openai/v1) — emitting those server-side
// via captureSafe so PostHog has both perspectives merged on the same
// distinctId. These client emits supply the user-facing latency picture
// (TTFT, render time) that the server cannot see.
// server (apps/server/src/routes/openai/v1), which records them as
// Postgres `product_events` rows — deliberately NOT forwarded to PostHog
// (per-request volume stays in DB/Grafana). These client emits supply the
// user-facing latency picture (TTFT, render time) the server cannot see.
function trackMessageSendStarted(properties: { source: 'text' | 'voice', model?: string }) {
if (!canCapture())
@ -878,20 +940,6 @@ export function useAnalytics() {
})
}
function trackModelChanged(properties: {
from_model?: string
to_model: string
provider: string
reason: 'manual' | 'auto'
}) {
if (!canCapture())
return
posthog.capture('model_changed', {
...properties,
surface: getConversationAnalyticsSurface(),
})
}
function trackOfficialTtsPreviewSucceeded(properties: Omit<TtsVoiceBaseProperties, 'source'> & {
voice_id: string
voice_type: VoiceType
@ -969,6 +1017,38 @@ export function useAnalytics() {
posthog.capture('autonomous_generate_text', properties)
}
// ─── AIRI card (ccv3 character card) events ──────────────────────────
// `card_created` is emitted store-side (`stores/modules/airi-card.ts`)
// because creation has three entry points; edit has exactly one
// user-driven entry (the creation dialog in edit mode), so it lives
// here. Background card writes (autonomous artistry, image journal,
// scene background) intentionally do NOT count as edits.
function trackCardEdited(properties: { card_id: string }) {
if (!canCapture())
return
posthog.capture('card_edited', {
...properties,
surface: getConversationAnalyticsSurface(),
})
}
/** Stage background switched on the active card. `cleared` = set to none. */
function trackSceneBackgroundSet(properties: { source: 'scene_settings' | 'card_gallery', cleared: boolean }) {
if (!canCapture())
return
posthog.capture('scene_background_set', {
...properties,
surface: getConversationAnalyticsSurface(),
})
}
function trackCharacterUpdated(properties: { character_id: string }) {
if (!canCapture())
return
posthog.capture('character_updated', properties)
}
// ─── App lifecycle ───────────────────────────────────────────────────
function trackAppLoaded(properties: { platform: 'web' | 'desktop' | 'mobile', version: string, cold_start_ms?: number }) {
@ -1058,6 +1138,87 @@ export function useAnalytics() {
})
}
// ─── Data maintenance (churn-precursor signals) ──────────────────────
/**
* One event for every destructive/exporting action on the data settings
* page. Wipes and exports often precede churn, so cohorts built on this
* event feed the at-risk-user list. Fires only after the action
* succeeded a failed wipe is not a churn signal.
*/
function trackDataAction(properties: {
action: 'chats_exported' | 'chats_imported' | 'chats_cleared' | 'app_data_cleared' | 'models_cache_cleared' | 'modules_settings_reset' | 'provider_settings_reset' | 'desktop_state_reset'
}) {
if (!canCapture())
return
posthog.capture('data_action', {
...properties,
surface: getConversationAnalyticsSurface(),
})
}
// ─── Desktop (Electron / Tamagotchi) differentiators ─────────────────
// These measure whether the desktop-only surfaces earn their upkeep:
// spotlight quick-input, floating widgets, the in-app updater, MCP
// server management. Input text never leaves the device — events carry
// counts and low-cardinality ids only.
function trackSpotlightUsed() {
if (!canCapture())
return
posthog.capture('spotlight_used')
}
function trackWidgetOpened(properties: { widget_id: string }) {
if (!canCapture())
return
posthog.capture('widget_opened', properties)
}
function trackUpdateCheckClicked(properties: { channel: string }) {
if (!canCapture())
return
posthog.capture('update_check_clicked', properties)
}
function trackUpdateDownloaded(properties: { channel: string, version?: string }) {
if (!canCapture())
return
posthog.capture('update_downloaded', properties)
}
/** User confirmed restart-and-install; the app quits right after. */
function trackUpdateInstallClicked(properties: { channel: string, version?: string }) {
if (!canCapture())
return
posthog.capture('update_install_clicked', properties, { send_instantly: true, transport: 'sendBeacon' })
}
function trackMcpServerAdded() {
if (!canCapture())
return
posthog.capture('mcp_server_added')
}
function trackMcpServerRemoved() {
if (!canCapture())
return
posthog.capture('mcp_server_removed')
}
function trackMcpConnectionTestRun(properties: { success: boolean }) {
if (!canCapture())
return
posthog.capture('mcp_connection_test_run', properties)
}
/** Pairing QR revealed — the funnel start for `device_channel_connected`. */
function trackDevicePairingQrShown() {
if (!canCapture())
return
posthog.capture('device_pairing_qr_shown')
}
// ─── Voice clone (custom TTS voice) ──────────────────────────────────
function trackVoiceCloneCreated(properties: { provider: string }) {
@ -1082,11 +1243,15 @@ export function useAnalytics() {
trackPlanSelected,
trackCheckoutStarted,
trackPaywallSeen,
trackSignup,
trackSignupCompleted,
trackOauthCallbackFailed,
trackPasswordChanged,
trackPasswordResetRequested,
trackOauthProviderLinkStarted,
trackOauthProviderUnlinked,
trackAccountDeletionRequested,
trackOnboardingStarted,
trackOnboardingCompleted,
trackFirstModelSelected,
trackCharacterCreated,
trackVoiceModeActivated,
trackModelSwitched,
@ -1146,7 +1311,6 @@ export function useAnalytics() {
trackVoicePackBound,
trackAttachmentUploaded,
trackPresetUsed,
trackModelChanged,
trackProviderSwitched,
trackSettingsChanged,
trackSupportContacted,
@ -1159,6 +1323,9 @@ export function useAnalytics() {
trackAppLoaded,
trackCardEdited,
trackSceneBackgroundSet,
trackCharacterUpdated,
trackCharacterDeleted,
trackCharacterSwitched,
trackChatSessionDeleted,
@ -1172,5 +1339,16 @@ export function useAnalytics() {
trackFeatureUsed,
trackVoiceCloneCreated,
trackDeviceChannelConnected,
trackDataAction,
trackSpotlightUsed,
trackWidgetOpened,
trackUpdateCheckClicked,
trackUpdateDownloaded,
trackUpdateInstallClicked,
trackMcpServerAdded,
trackMcpServerRemoved,
trackMcpConnectionTestRun,
trackDevicePairingQrShown,
}
}

View file

@ -52,4 +52,75 @@ describe('useLinkedAccounts', () => {
errorCallbackURL: 'https://accounts.airi.build/ui/profile',
})
})
it('fires analytics hooks on unlink success and link handoff, but not on failure', async () => {
const onUnlinked = vi.fn()
const onLinkStarted = vi.fn()
const unlinkAccount = vi.fn(async (): Promise<{ data: unknown, error: { message?: string } | null }> => ({ data: null, error: null }))
const linkSocial = vi.fn(async (): Promise<{ data: { url?: string, redirect?: boolean, status?: boolean } | null, error: { message?: string } | null }> => ({
data: { status: true, redirect: false },
error: null,
}))
const holder: {
linkedAccounts?: ReturnType<typeof useLinkedAccounts>
} = {}
const app = createSSRApp({
setup() {
holder.linkedAccounts = useLinkedAccounts({
client: {
// Two rows so `isLastSignInMethod` doesn't veto the unlink.
listAccounts: vi.fn(async () => ({
data: [
{ id: '1', accountId: 'a-1', providerId: 'github', createdAt: '2026-01-01T00:00:00Z', scopes: [] },
{ id: '2', accountId: 'a-2', providerId: 'credential', createdAt: '2026-01-01T00:00:00Z', scopes: [] },
],
error: null,
})),
unlinkAccount,
linkSocial,
},
isAuthenticated: ref(false),
describeError: () => 'boom',
buildCallbackURL: () => 'https://accounts.airi.build/ui/profile',
messages: {
listFailed: 'list failed',
unlinkFailed: 'unlink failed',
linkFailed: 'link failed',
lastAccount: 'last account',
unlinked: provider => `${provider} unlinked`,
linkStarted: provider => `${provider} link started`,
},
onUnlinked,
onLinkStarted,
})
return () => null
},
})
await renderToString(app)
if (!holder.linkedAccounts)
throw new Error('Expected linked accounts composable to initialize')
await holder.linkedAccounts.refresh()
await holder.linkedAccounts.unlink('github', 'GitHub')
expect(onUnlinked).toHaveBeenCalledTimes(1)
expect(onUnlinked).toHaveBeenCalledWith('github')
await holder.linkedAccounts.link('google', 'Google')
expect(onLinkStarted).toHaveBeenCalledTimes(1)
expect(onLinkStarted).toHaveBeenCalledWith('google')
// Failure paths must not fire the hooks — a failed unlink is not an
// unlink, and a failed handoff never reached the provider.
unlinkAccount.mockResolvedValueOnce({ data: null, error: { message: 'nope' } })
await holder.linkedAccounts.unlink('github', 'GitHub')
expect(onUnlinked).toHaveBeenCalledTimes(1)
linkSocial.mockResolvedValueOnce({ data: null, error: { message: 'nope' } })
await holder.linkedAccounts.link('google', 'Google')
expect(onLinkStarted).toHaveBeenCalledTimes(1)
})
})

View file

@ -76,6 +76,21 @@ export interface UseLinkedAccountsArgs {
* and hash-history routers without further configuration.
*/
buildCallbackURL?: () => string
/**
* Analytics hook fires exactly once per successful unlink, after the
* server confirmed the removal. Success is only knowable inside this
* composable (errors are swallowed into the `error` ref for the UI), so
* callers that need to observe it must hook here instead of inspecting
* refs after `unlink()` resolves.
*/
onUnlinked?: (providerId: string) => void
/**
* Analytics hook fires right before the OAuth consent redirect
* navigates away (or after a synchronous link succeeded). Link
* completion happens on the provider's site and is not observable
* from this page; treat this as "link attempt handed off".
*/
onLinkStarted?: (providerId: string) => void
}
/**
@ -173,6 +188,7 @@ export function useLinkedAccounts(args: UseLinkedAccountsArgs) {
if (apiError)
throw new Error(apiError.message ?? 'unlinkAccount failed')
message.value = args.messages.unlinked(providerName)
args.onUnlinked?.(providerId)
await refresh()
}
catch (err) {
@ -201,11 +217,13 @@ export function useLinkedAccounts(args: UseLinkedAccountsArgs) {
if (apiError)
throw new Error(apiError.message ?? 'linkSocial failed')
if (data?.url) {
args.onLinkStarted?.(providerId)
window.location.assign(data.url)
return
}
// No URL came back (e.g. provider returned success synchronously) —
// refresh so the new row shows up without a navigation.
args.onLinkStarted?.(providerId)
await refresh()
}
catch (err) {

View file

@ -88,9 +88,9 @@ export function registerPosthogBuildInfo(buildInfo: AboutBuildInfo): void {
* land on the anonymous device person, PostHog cannot join them.
*
* Expects:
* - `userId` is the Better Auth user id (`user.id`) must match what
* `apps/server/src/routes/stripe/index.ts` passes as `distinctId` in
* `capturePaymentCompleted`.
* - `userId` is the Better Auth user id (`user.id`) the same value the
* server-side product-events forwarder passes as `distinctId` (see
* `apps/server/src/services/domain/product-events.ts`).
*/
export function identifyPosthogUser(userId: string): void {
if (!posthogInitialized || posthog.has_opted_out_capturing())

View file

@ -115,9 +115,16 @@ export const useAiriCardStore = defineStore('airi-card', () => {
activeSpeechModel,
} = storeToRefs(speechStore)
const addCard = (card: AiriCard | Card | ccv3.CharacterCardV3) => {
/**
* `source` feeds the `card_created` analytics event: `scratch` = built in
* the creation dialog, `import` = ccv3 JSON upload, `duplicate` = cloned
* from an existing card (profile switcher). Required so a new call site
* can't silently degrade creation attribution.
*/
const addCard = (card: AiriCard | Card | ccv3.CharacterCardV3, source: 'scratch' | 'import' | 'duplicate') => {
const newCardId = nanoid()
cards.value.set(newCardId, newAiriCard(card))
capturePosthogEvent('card_created', { card_id: newCardId, source })
return newCardId
}

33
pnpm-lock.yaml generated
View file

@ -873,6 +873,9 @@ catalogs:
posthog-js:
specifier: 1.306.1
version: 1.306.1
posthog-node:
specifier: ^5.39.4
version: 5.39.4
postprocessing:
specifier: ^6.39.1
version: 6.39.1
@ -1523,6 +1526,9 @@ importers:
pg:
specifier: 'catalog:'
version: 8.20.0
posthog-node:
specifier: 'catalog:'
version: 5.39.4(rxjs@7.8.2)
resend:
specifier: 'catalog:'
version: 6.12.2
@ -9546,9 +9552,15 @@ packages:
'@polka/url@1.0.0-next.29':
resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==}
'@posthog/core@1.39.6':
resolution: {integrity: sha512-o6ajIwN5zXoNP0D4H/QPmOyibNTUkSyOR6ya7AG5U2ywXx4awo72L2KnCoiZPQM5x/bXv6jPBdimH8M18Ax0aw==}
'@posthog/core@1.7.1':
resolution: {integrity: sha512-kjK0eFMIpKo9GXIbts8VtAknsoZ18oZorANdtuTj1CbgS28t4ZVq//HAWhnxEuXRTrtkd+SUJ6Ux3j2Af8NCuA==}
'@posthog/types@1.392.1':
resolution: {integrity: sha512-Qg6Gl7/1vlr8+gPtBi5gwnLgAgiyFoKOVmTvTtDcvya9cpTwZfna7rQmkGQ4B63CunUYNNbOlqcwiUwUDyTK6w==}
'@prisma/client@5.22.0':
resolution: {integrity: sha512-M0SVXfyHnQREBKxCgyo7sffrKttwE6R8PMq330MIUF0pTwjUhLbW84pFDlf06B27XyCR++VtjugEnIHdr07SVA==}
engines: {node: '>=16.13'}
@ -16527,6 +16539,15 @@ packages:
posthog-js@1.306.1:
resolution: {integrity: sha512-wO7bliv/5tlAlfoKCUzwkGXZVNexk0dHigMf9tNp0q1rzs62wThogREY7Tz7h/iWKYiuXy1RumtVlTmHuBXa1w==}
posthog-node@5.39.4:
resolution: {integrity: sha512-+fCQ7htBFRQQFbIzl1T0TA7bDwYyaB9XP308ZFMCUoB5LzTzOFxBa6TYVrxdH/VQl43WXTp6sf0QsG2Z4XlNBg==}
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'}
@ -23600,10 +23621,16 @@ snapshots:
'@polka/url@1.0.0-next.29': {}
'@posthog/core@1.39.6':
dependencies:
'@posthog/types': 1.392.1
'@posthog/core@1.7.1':
dependencies:
cross-spawn: 7.0.6
'@posthog/types@1.392.1': {}
'@prisma/client@5.22.0': {}
'@proj-airi/chromatic@1.1.1':
@ -31706,6 +31733,12 @@ snapshots:
preact: 10.28.1
web-vitals: 4.2.4
posthog-node@5.39.4(rxjs@7.8.2):
dependencies:
'@posthog/core': 1.39.6
optionalDependencies:
rxjs: 7.8.2
postject@1.0.0-alpha.6:
dependencies:
commander: 9.5.0

View file

@ -20,6 +20,7 @@ overrides:
safer-buffer: npm:@nolyfill/safer-buffer@^1.0.44
side-channel: npm:@nolyfill/side-channel@^1.0.44
string.prototype.matchall: npm:@nolyfill/string.prototype.matchall@^1.0.44
patchedDependencies:
'@mediapipe/tasks-vision': patches/@mediapipe__tasks-vision.patch
'@xsai/generate-text@0.5.0-beta.2': patches/@xsai__generate-text@0.5.0-beta.2.patch
@ -320,6 +321,7 @@ catalog:
postcss: ^8.5.10
postgres: ^3.4.9
posthog-js: 1.306.1
posthog-node: ^5.39.4
postprocessing: ^6.39.1
prismarine-block: ^1.23.0
prismarine-entity: ^2.6.0
@ -452,7 +454,6 @@ onlyBuiltDependencies:
- uiohook-napi
- utf-8-validate
- vue-demi
packageExtensions:
'@formkit/auto-animate':
peerDependencies:

View file

@ -22,4 +22,12 @@ export const POSTHOG_PROJECT_KEY
export const DEFAULT_POSTHOG_CONFIG = {
api_host: 'https://us.i.posthog.com',
person_profiles: 'identified_only', // or 'always' to create profiles for anonymous users as well
// Without this, posthog-js only fires `$pageview` on the initial page load.
// Every AIRI surface is an SPA (vue-router / VitePress client routing), so
// route changes would be invisible in PostHog. The '2025-05-24' defaults
// preset switches `capture_pageview` to 'history_change' — and because
// `capture_pageleave` defaults to 'if_capture_pageview', every surface
// that spreads this config also starts emitting `$pageleave`. That is
// intentional: pageleave is what makes route-level dwell time queryable.
defaults: '2025-05-24',
} as const satisfies Partial<PostHogConfig>