diff --git a/apps/server/docs/ai-context/metrics-ownership.md b/apps/server/docs/ai-context/metrics-ownership.md index 9a0229a4d..18099d893 100644 --- a/apps/server/docs/ai-context/metrics-ownership.md +++ b/apps/server/docs/ai-context/metrics-ownership.md @@ -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) diff --git a/apps/server/docs/ai-context/verifications/posthog-forwarding-and-pageview.md b/apps/server/docs/ai-context/verifications/posthog-forwarding-and-pageview.md new file mode 100644 index 000000000..40987a8fa --- /dev/null +++ b/apps/server/docs/ai-context/verifications/posthog-forwarding-and-pageview.md @@ -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`)测试事件,分析时按事件名过滤。 diff --git a/apps/server/package.json b/apps/server/package.json index 8df53757d..bebf6dd2f 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -61,6 +61,7 @@ "nanoid": "catalog:", "ofetch": "catalog:", "pg": "catalog:", + "posthog-node": "catalog:", "resend": "catalog:", "stripe": "catalog:", "unspeech": "catalog:xsai", diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index bdde38f65..c47f6819d 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -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', { diff --git a/apps/server/src/libs/env.ts b/apps/server/src/libs/env.ts index 3de03c850..510883d05 100644 --- a/apps/server/src/libs/env.ts +++ b/apps/server/src/libs/env.ts @@ -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'), diff --git a/apps/server/src/services/adapters/posthog.ts b/apps/server/src/services/adapters/posthog.ts new file mode 100644 index 000000000..82c9ef4e9 --- /dev/null +++ b/apps/server/src/services/adapters/posthog.ts @@ -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 +} + +/** + * 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 + /** Flush and close the underlying client. Call on server shutdown. */ + shutdown: () => Promise +} + +/** + * 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 { + 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 { + await client.shutdown() + }, + } +} diff --git a/apps/server/src/services/domain/product-events.test.ts b/apps/server/src/services/domain/product-events.test.ts index 73bec67c6..05526bdbf 100644 --- a/apps/server/src/services/domain/product-events.test.ts +++ b/apps/server/src/services/domain/product-events.test.ts @@ -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' }) + }) }) diff --git a/apps/server/src/services/domain/product-events.ts b/apps/server/src/services/domain/product-events.ts index a1f4cef91..036238eb0 100644 --- a/apps/server/src/services/domain/product-events.ts +++ b/apps/server/src/services/domain/product-events.ts @@ -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> = { + 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 { * 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 { + // 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 { diff --git a/apps/stage-tamagotchi/src/renderer/pages/about.vue b/apps/stage-tamagotchi/src/renderer/pages/about.vue index 904100ac2..b7272dd16 100644 --- a/apps/stage-tamagotchi/src/renderer/pages/about.vue +++ b/apps/stage-tamagotchi/src/renderer/pages/about.vue @@ -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(() => {
{{ restartButtonLabel }}