mirror of
https://github.com/moeru-ai/airi.git
synced 2026-07-09 15:58:27 +00:00
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
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:
parent
5aa44aedd3
commit
0642ede8d1
46 changed files with 1133 additions and 92 deletions
|
|
@ -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 connector;server 请求路径只写 Postgres/Grafana。
|
||||
落地分两步,**不要一次性埋全部事件**,否则 schema 漂移会很快出现。PostHog 采集以前端为主;服务端只经 product-events 白名单转发业务事实(注册、支付、订阅),per-request 路径仍只写 Postgres/Grafana。
|
||||
|
||||
### 阶段 1(P0 — 付费漏斗 + 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 store,auth 端在 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`
|
||||
|
||||
### 阶段 2(P1 — retention / feature adoption / churn)
|
||||
|
||||
|
|
|
|||
|
|
@ -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.4;posthog-js 1.306.1;Node 26.3.0
|
||||
|
||||
## 用户路径
|
||||
|
||||
- **场景 1**:用户完成 Stripe 支付 → webhook 写 `product_events` → 服务端把 `payment_completed` 转发到 PostHog(distinctId = 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`)测试事件,分析时按事件名过滤。
|
||||
|
|
@ -61,6 +61,7 @@
|
|||
"nanoid": "catalog:",
|
||||
"ofetch": "catalog:",
|
||||
"pg": "catalog:",
|
||||
"posthog-node": "catalog:",
|
||||
"resend": "catalog:",
|
||||
"stripe": "catalog:",
|
||||
"unspeech": "catalog:xsai",
|
||||
|
|
|
|||
|
|
@ -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', {
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
59
apps/server/src/services/adapters/posthog.ts
Normal file
59
apps/server/src/services/adapters/posthog.ts
Normal 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()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -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' })
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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[]> {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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']">
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'))
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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?
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
167
apps/ui-server-auth/src/modules/analytics.ts
Normal file
167
apps/ui-server-auth/src/modules/analytics.ts
Normal 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)
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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' })
|
||||
},
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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' })
|
||||
},
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -127,7 +127,7 @@ function confirmCreate() {
|
|||
const newId = cardStore.addCard({
|
||||
...structuredClone(toRaw(current)),
|
||||
name,
|
||||
})
|
||||
}, 'duplicate')
|
||||
|
||||
activeCardId.value = newId
|
||||
cancelCreate()
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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
33
pnpm-lock.yaml
generated
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue