diff --git a/packages/client/product-analytics/README.i18n.yaml b/packages/client/product-analytics/README.i18n.yaml index 200ba4f9e9..0847f8f241 100644 --- a/packages/client/product-analytics/README.i18n.yaml +++ b/packages/client/product-analytics/README.i18n.yaml @@ -15,8 +15,8 @@ en: 2fee9973f19f9401 zh: 8ab4b55413cb8408 /deepseek-ai-dsh-client-product-analytics/use-this-package: - en: c8c05edf30b3959d - zh: 39c2147cf01d43b5 + en: 6bd7bf60e279ba1c + zh: afd55f06585c6462 /deepseek-ai-dsh-client-product-analytics/understand-the-implementation: en: 416b7e925f0852c9 zh: c0b83bacf18e91b1 diff --git a/packages/client/product-analytics/README.md b/packages/client/product-analytics/README.md index 84c67e30e2..307534adb4 100644 --- a/packages/client/product-analytics/README.md +++ b/packages/client/product-analytics/README.md @@ -29,9 +29,9 @@ Common fields are `device_id`, `user_id`, `os_version`, and `app_version`. Devic Electron forwards its build-inlined `DSH_CLIENT_VERSION` to the Host; analytics and the exporter reuse this client version. The exporter requires it. Desktop export, batch, and shutdown deadlines are 1000, 1500, and 2000 milliseconds, below the Host exit budget. -The [event types](src/events.ts) own names and allowed fields. Authentication events cover the native welcome page only; workspace login after API-key entry is excluded. Views count visible page entry, including reshown native welcome windows; transient onboarding loading does not repeat the same page impression, and closing an onboarding popup emits nothing. Funded Continue uses `next`. Message submission retains its original occurrence timestamp and captures `submit_source`, `submit_type`, model, explicit effort, and `run_mode` before asynchronous command adjudication; only the ordinary message path reports before reference serialization, so handled and claimed commands are excluded. Later serialization, attachment, or send failures do not retract this attempt; queue execution does not count again. Attachment-only submissions follow the same rule. Programmatic submissions omit `submit_source`; keyboard and button gestures use `enter` and `click`. Plan takes precedence over an active goal. Capture and reporting failures cannot interrupt submission. A blank first-send Session has no `session_id`. Model and plugin switches report only accepted changes. Fork events carry the created child ID and the source IDs, before the optional child-title update; failed creation emits nothing. +The [event types](src/events.ts) own names and allowed fields. Authentication events cover the native welcome page only; workspace login after API-key entry is excluded. Views count visible page entry, including reshown native welcome windows; transient onboarding loading does not repeat the same page impression, and closing an onboarding popup reports `button_name=close`. Funded Continue uses `continue`. Message submission retains its original occurrence timestamp and captures `msg_type=default|queue|steer`, model, explicit effort, and `run_mode` before asynchronous command adjudication; only the ordinary message path reports before reference serialization, so handled and claimed commands are excluded. Later serialization, attachment, or send failures do not retract this attempt; queue execution does not count again. Attachment-only submissions follow the same rule. Plan takes precedence over an active goal. Capture and reporting failures cannot interrupt submission. Message submissions and model or effort switches omit `session_id` while the Session is blank. Model and plugin switches report only accepted changes. Fork events carry the created child ID and the source IDs, before the optional child-title update; failed creation emits nothing. -Plugin rows use `plugin_type=plugin`, and packages use `bundle`. Installation reports `result_status=success|failed|cancelled|unknown` independently from `error_reason` and keeps `is_success`. Duration is milliseconds from the install click through the terminal result, including validation, inspection, and internal registry retries. `input_value` retains registry package identifiers and plain versions only; Git inputs become `[git]`, other URLs `[url]`, and paths or unrecognized inputs `[path-or-other]`. Both click and result use this redacted value. Reopening a hidden install dialog counts another `plugin_add_button_click`. `is_builtin` means installation-supplied (`installed=false`), not whether a profile explicitly depends on the package. Explicit build-approval retry starts a new attempt. Restart-required is success. A temporary disconnect retains the pending operation; only an absent recovery result becomes `unknown`. +Only `plugin_toggle` carries `plugin_type`: plugin rows use `plugin`, and packages use `bundle`. Installation reports `is_success`; cancellation uses `is_success=false` and `error_reason=user_cancelled`, and an absent recovery result uses `is_success=false` and `error_reason=unknown_result`. Duration is milliseconds from the install click through the terminal result, including validation, inspection, and internal registry retries. `input_value` retains registry package identifiers and plain versions only; Git inputs become `[git]`, other URLs `[url]`, and paths or unrecognized inputs `[path-or-other]`. Both click and result use this redacted value. Reopening a hidden install dialog counts another `plugin_add_button_click`. `is_builtin` means installation-supplied (`installed=false`), not whether a profile explicitly depends on the package. Explicit build-approval retry starts a new attempt. Restart-required is success. A temporary disconnect retains the pending operation; only an absent recovery result reports `unknown_result`. ## Understand the implementation diff --git a/packages/client/product-analytics/README.zh.md b/packages/client/product-analytics/README.zh.md index f997f49dc3..1a87f9fd0c 100644 --- a/packages/client/product-analytics/README.zh.md +++ b/packages/client/product-analytics/README.zh.md @@ -29,9 +29,9 @@ kind: "package-reference" Electron 将构建内联的 `DSH_CLIENT_VERSION` 传给 Host;埋点与导出器复用这一客户端版本,导出器要求该值存在。桌面导出、批次与关闭期限分别为 1000、1500 和 2000 毫秒,均短于 Host 退出预算。 -认证事件仅覆盖原生欢迎页;通过 API Key 进入工作区后再登录的场景不在采集范围内。[事件类型](src/events.ts)定义名称和允许的字段。页面曝光按实际进入可见页面计数,包括重新显示的原生欢迎窗口;onboarding 短暂进入加载状态不会重复计算同一页面曝光,关闭 onboarding 弹窗不产生事件。有余额时的继续按钮使用 `next`。消息提交保留最初发生时间,并在异步命令裁决前采集 `submit_source`、`submit_type`、模型、显式思考强度和 `run_mode`;仅普通消息路径在引用序列化之前上报,已处理或认领的命令不计入。后续序列化、附件处理或发送失败不撤销此次计数,排队后的执行也不重复计数;纯附件提交遵循相同规则。程序化提交省略 `submit_source`;键盘与按钮操作分别使用 `enter` 和 `click`。计划模式优先于活跃目标。采集和上报异常不会中断提交。首次发送的空白会话省略 `session_id`。模型与插件切换仅在变更被接受后上报。分叉事件携带已创建的子会话 ID 和来源 ID,在可选的子会话标题更新前上报;创建失败不产生事件。 +认证事件仅覆盖原生欢迎页;通过 API Key 进入工作区后再登录的场景不在采集范围内。[事件类型](src/events.ts)定义名称和允许的字段。页面曝光按实际进入可见页面计数,包括重新显示的原生欢迎窗口;onboarding 短暂进入加载状态不会重复计算同一页面曝光,关闭 onboarding 弹窗上报 `button_name=close`。有余额时的继续按钮使用 `continue`。消息提交保留最初发生时间,并在异步命令裁决前采集 `msg_type=default|queue|steer`、模型、显式思考强度和 `run_mode`;仅普通消息路径在引用序列化之前上报,已处理或认领的命令不计入。后续序列化、附件处理或发送失败不撤销此次计数,排队后的执行也不重复计数;纯附件提交遵循相同规则。计划模式优先于活跃目标。采集和上报异常不会中断提交。消息提交以及模型或思考强度切换在空白会话中均省略 `session_id`。模型与插件切换仅在变更被接受后上报。分叉事件携带已创建的子会话 ID 和来源 ID,在可选的子会话标题更新前上报;创建失败不产生事件。 -插件条目使用 `plugin_type=plugin`,插件包使用 `bundle`。安装通过独立于 `error_reason` 的 `result_status=success|failed|cancelled|unknown` 表达结果,并保留 `is_success`。耗时单位为毫秒,从点击安装开始,到最终结果结束,包含校验、检查输入和内部源重试。`input_value` 仅保留 registry 包标识和普通版本;Git 输入记为 `[git]`,其他 URL 记为 `[url]`,路径及无法识别的输入记为 `[path-or-other]`。点击和结果均使用脱敏后的值。重新打开已隐藏的安装弹窗会再次计为 `plugin_add_button_click`。`is_builtin` 表示由安装供应(`installed=false`),不表示 profile 是否显式依赖该包。显式批准构建后的重试开启新的一次尝试。需要重启视为成功。临时断线保留待定操作;只有恢复查询确认没有结果时才成为 `unknown`。 +只有 `plugin_toggle` 携带 `plugin_type`:插件条目使用 `plugin`,插件包使用 `bundle`。安装通过 `is_success` 表达结果;取消使用 `is_success=false` 和 `error_reason=user_cancelled`,恢复查询确认无结果时使用 `is_success=false` 和 `error_reason=unknown_result`。耗时单位为毫秒,从点击安装开始,到最终结果结束,包含校验、检查输入和内部源重试。`input_value` 仅保留 registry 包标识和普通版本;Git 输入记为 `[git]`,其他 URL 记为 `[url]`,路径及无法识别的输入记为 `[path-or-other]`。点击和结果均使用脱敏后的值。重新打开已隐藏的安装弹窗会再次计为 `plugin_add_button_click`。`is_builtin` 表示由安装供应(`installed=false`),不表示 profile 是否显式依赖该包。显式批准构建后的重试开启新的一次尝试。需要重启视为成功。临时断线保留待定操作;只有恢复查询确认没有结果时才上报 `unknown_result`。 ## 了解实现 diff --git a/packages/client/product-analytics/src/events.ts b/packages/client/product-analytics/src/events.ts index 18e7c72e77..7fc64a6bcc 100644 --- a/packages/client/product-analytics/src/events.ts +++ b/packages/client/product-analytics/src/events.ts @@ -11,11 +11,11 @@ export interface ProductEventMap { onboarding_page_view: { page_name: OnboardingPage } onboarding_page_click: { page_name: OnboardingPage - button_name: 'next' | 'back' | 'skip' | 'charge' | 'later' + button_name: 'next' | 'back' | 'skip' | 'charge' | 'later' | 'continue' selected_content?: 'office' | 'code' | 'code_office' | 'focus_result' | 'key_detail' | 'full_process' } onboarding_popup_view: { popup_name: 'skip_charge' | 'skip_setting' } - onboarding_popup_click: { popup_name: 'skip_charge' | 'skip_setting'; button_name: 'charge' | 'know' | 'enter' | 'setting' } + onboarding_popup_click: { popup_name: 'skip_charge' | 'skip_setting'; button_name: 'charge' | 'know' | 'enter' | 'setting' | 'close' } desktop_upgrade_click: Record desktop_upgrade_download_result: { is_success: boolean; error_reason?: string } desktop_upgrade_install_restart_click: Record @@ -24,8 +24,7 @@ export interface ProductEventMap { model_name?: string thinking_effort?: string run_mode: 'plan' | 'goal' | 'default' - submit_source?: 'click' | 'enter' - submit_type: 'normal' | 'steer' | 'queue' + msg_type: 'default' | 'steer' | 'queue' } model_switch: { session_id?: SessionId; switch_from: string; switch_to: string } thinking_level_switch: { session_id?: SessionId; switch_from: string; switch_to: string; model_name: string } @@ -39,17 +38,15 @@ export interface ProductEventMap { sidebar_menu_click: { menu_name: 'plugin' | 'cron' } plugin_toggle: { plugin_name: string; plugin_type: 'plugin' | 'bundle'; is_enabled: boolean; is_builtin: boolean } plugin_add_button_click: Record - plugin_install_click: { input_value: string; plugin_type: 'bundle' } + plugin_install_click: { input_value: string } install_plugin_result: { input_value: string - plugin_type: 'bundle' is_success: boolean - result_status: 'success' | 'failed' | 'cancelled' | 'unknown' error_reason?: string duration: number plugin_name?: string } - confirm_uninstall_plugin: { plugin_name: string; plugin_type: 'bundle' } + confirm_uninstall_plugin: { plugin_name: string } } /** Public page names independent of internal onboarding state names. */ diff --git a/packages/client/ui-conversation/src/client/input/submission-analytics.ts b/packages/client/ui-conversation/src/client/input/submission-analytics.ts index 1fb86ad3ab..a0f1a8e1c9 100644 --- a/packages/client/ui-conversation/src/client/input/submission-analytics.ts +++ b/packages/client/ui-conversation/src/client/input/submission-analytics.ts @@ -9,14 +9,13 @@ import type { MessageSubmission } from '../contract/composer-submission.ts' * @param submission - original occurrence and Session snapshot. */ export function reportMessageSubmission(ctx: Context, submission: MessageSubmission): void { - const { state, timestamp, source, mode } = submission + const { state, timestamp, mode } = submission if (state === undefined) return const model = state.model ctx.get('productAnalytics')?.track('send_button_click', { ...state.sessionId === undefined ? {} : { session_id: state.sessionId }, ...model === undefined ? {} : { model_name: `${model.provider}/${model.name}`, ...model.effort === undefined ? {} : { thinking_effort: model.effort } }, run_mode: state.runMode, - ...source === undefined ? {} : { submit_source: source }, - submit_type: state.running ? mode : 'normal', + msg_type: state.running ? mode : 'default', }, timestamp) } diff --git a/packages/client/ui-conversation/tests/service-orchestration.client.spec.ts b/packages/client/ui-conversation/tests/service-orchestration.client.spec.ts index 05f6e815c7..6665284ae4 100644 --- a/packages/client/ui-conversation/tests/service-orchestration.client.spec.ts +++ b/packages/client/ui-conversation/tests/service-orchestration.client.spec.ts @@ -890,10 +890,25 @@ it('captures run mode and queue intent at submission before later projection cha b.shell.setDraft('private prompt') b.shell.submit('queue', 'click') expect(track).toHaveBeenCalledExactlyOnceWith('send_button_click', { - session_id: 's1', model_name: 'fixture/model', thinking_effort: 'high', run_mode: 'plan', submit_source: 'click', submit_type: 'queue', + session_id: 's1', model_name: 'fixture/model', thinking_effort: 'high', run_mode: 'plan', msg_type: 'queue', }, expect.any(Number)) await b.runtime.sessions.setProjection('s1', 'plan', { active: false, pending: false }) expect(track).toHaveBeenCalledTimes(1) expect(JSON.stringify(track.mock.calls)).not.toContain('private prompt') } finally { await b.runtime.dispose() } }) + +it.each([ + { running: false, mode: 'queue', expected: 'default' }, + { running: true, mode: 'steer', expected: 'steer' }, +] as const)('reports msg_type=$expected without gesture fields', async ({ running, mode, expected }) => { + const b = await bench() + const track = vi.fn() + b.runtime.ctx.provide('productAnalytics', { enabled: true, track } as never) + try { + await b.runtime.sessions.updateSessionSnapshot('s1', (state) => { state.running = running; state.blank = true }) + b.shell.setDraft('message') + b.shell.submit(mode, 'enter') + expect(track).toHaveBeenCalledExactlyOnceWith('send_button_click', { run_mode: 'default', msg_type: expected }, expect.any(Number)) + } finally { await b.runtime.dispose() } +}) diff --git a/packages/client/ui-model-selection/src/client/directory.ts b/packages/client/ui-model-selection/src/client/directory.ts index ad885ffac6..41f67832a0 100644 --- a/packages/client/ui-model-selection/src/client/directory.ts +++ b/packages/client/ui-model-selection/src/client/directory.ts @@ -53,6 +53,7 @@ export class ModelDirectory { * @param available - whether this session may use Agent-bound model RPCs. * @param catalog - Host-generation catalog shared by every Session. * @param projected - durable model selection projected from Session history. + * @param isBlank - whether this Session has no first message yet. * @param track - desktop-only callback after a successful user selection. */ constructor( @@ -61,6 +62,7 @@ export class ModelDirectory { private readonly available: () => boolean, private readonly catalog: ModelCatalogDirectory, private readonly projected: ObservableSnapshot, + private readonly isBlank: () => boolean, private readonly track?: TrackProductEvent, ) { this.unsubscribeCatalog = catalog.store.subscribe(() => { this.syncInputs() }) @@ -115,9 +117,9 @@ export class ModelDirectory { if (previous !== null) { const from = `${previous.provider}/${previous.model}` const to = `${selection.provider}/${selection.model}` - if (from !== to) this.track?.('model_switch', { session_id: this.sessionId, switch_from: from, switch_to: to }) + if (from !== to) this.track?.('model_switch', { ...this.isBlank() ? {} : { session_id: this.sessionId }, switch_from: from, switch_to: to }) if (from === to && previousEffort !== nextEffort) this.track?.('thinking_level_switch', { - session_id: this.sessionId, model_name: to, switch_from: previousEffort ?? 'default', switch_to: nextEffort ?? 'default', + ...this.isBlank() ? {} : { session_id: this.sessionId }, model_name: to, switch_from: previousEffort ?? 'default', switch_to: nextEffort ?? 'default', }) } this.store.update((s) => { s.status = 'ready'; s.pending = null; s.error = null }) diff --git a/packages/client/ui-model-selection/src/client/service.ts b/packages/client/ui-model-selection/src/client/service.ts index a5c4d1ad62..18fa88cc86 100644 --- a/packages/client/ui-model-selection/src/client/service.ts +++ b/packages/client/ui-model-selection/src/client/service.ts @@ -78,6 +78,7 @@ export class ModelDirectoryResolver extends Service { () => sessions.subagentAddress(sessionId) === undefined, this.catalog, binding.session.projections.faceOf('modelSelection'), + () => binding.session.getSnapshot().blank, (name, attributes) => this.ctx.get('productAnalytics')?.track(name, attributes), ) live.directories.set(binding, directory) diff --git a/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts b/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts index 0719e8c476..1896332c02 100644 --- a/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts @@ -138,7 +138,11 @@ async function bench(locale: 'zh' | 'en' = 'zh') { const scopes = new Map() const bindings = new Map SnapshotStore } } + session: { + sessionId: SessionId + getSnapshot: () => { blank: boolean } + projections: { faceOf: () => SnapshotStore } + } ctx: Context }>() const addressed = new Set() @@ -154,7 +158,7 @@ async function bench(locale: 'zh' | 'en' = 'zh') { const fiber = ctx.plugin({ inject: [...inject], apply }) await fiber.await() await ctx.plugin(function probe() {}).await() - const mint = (key: string) => { + const mint = (key: string, blank = false) => { const id = sid(key) const handle = createScope(ctx, id) scopes.set(id, handle.ctx) @@ -165,7 +169,7 @@ async function bench(locale: 'zh' | 'en' = 'zh') { projections.set(id, projection) const binding = { sessionId: id, - session: { sessionId: id, projections: { faceOf: () => projection } }, + session: { sessionId: id, getSnapshot: () => ({ blank }), projections: { faceOf: () => projection } }, ctx: handle.ctx, } bindings.set(id, binding) @@ -528,17 +532,17 @@ describe('ui-model-selection dual entry', () => { }) -it('reports successful model and effort changes and no refused switch', async () => { +it.each([false, true])('reports accepted switches with blank=%s and no refused switch', async (blank) => { const b = await bench('en') - const scope = b.mint('analytics') + const scope = b.mint('analytics', blank) try { const face = b.seat().inject!(sid('analytics')) await face.select({ provider: 'deepseek-official', model: 'deepseek-v4-pro', reasoningEffort: 'max' }) - expect(b.track).toHaveBeenCalledWith('model_switch', expect.objectContaining({ switch_to: 'deepseek-official/deepseek-v4-pro' })) + expect(b.track).toHaveBeenCalledWith('model_switch', { ...blank ? {} : { session_id: 'analytics' }, switch_from: 'deepseek-official/deepseek-v4-flash', switch_to: 'deepseek-official/deepseek-v4-pro' }) b.track.mockClear() await face.select({ provider: 'deepseek-official', model: 'deepseek-v4-pro', reasoningEffort: 'high' }) expect(b.track).toHaveBeenCalledExactlyOnceWith('thinking_level_switch', { - session_id: 'analytics', model_name: 'deepseek-official/deepseek-v4-pro', switch_from: 'max', switch_to: 'high', + ...blank ? {} : { session_id: 'analytics' }, model_name: 'deepseek-official/deepseek-v4-pro', switch_from: 'max', switch_to: 'high', }) b.track.mockClear() b.rejectSelection() diff --git a/packages/client/ui-plugin-manager/src/client/manager-store.ts b/packages/client/ui-plugin-manager/src/client/manager-store.ts index b9e823b53b..1a3dde4a82 100644 --- a/packages/client/ui-plugin-manager/src/client/manager-store.ts +++ b/packages/client/ui-plugin-manager/src/client/manager-store.ts @@ -6,7 +6,7 @@ import { sanitizeInstallInput } from './sanitize-install-input.ts' * after each action and after every `plugin-manager/changed` event, so a * change made on another surface shows here without a manual refresh. */ -import type { ProductEventMap } from '@deepseek-ai/dsh-client-product-analytics/client' +import type {} from '@deepseek-ai/dsh-client-product-analytics/client' import { randomUUID } from '@deepseek-ai/dsh-util-crypto' import type { Context as ClientContext } from '@deepseek-ai/cordis' import type { @@ -615,7 +615,7 @@ export class PluginManagerController { }, uninstall: (packageName) => { this.pendingConfirm = () => this.run(packageName, { packageName, action: 'uninstall' }, async () => { - this.ctx.get('productAnalytics')?.track('confirm_uninstall_plugin', { plugin_name: packageName, plugin_type: 'bundle' }) + this.ctx.get('productAnalytics')?.track('confirm_uninstall_plugin', { plugin_name: packageName }) this.applied(await this.ctx.remote.pluginManager.removeBundle(packageName), packageName) }) this.patch({ confirm: { action: 'uninstall', packageName } }) @@ -790,7 +790,7 @@ export class PluginManagerController { if (install.phase === 'checking' || isInstallPending(install.phase) || spec === '') return if (this.ctx.get('productAnalytics')?.enabled) { this.analyticsAttempt = { input: sanitizeInstallInput(spec), started: Date.now() } - this.ctx.get('productAnalytics')?.track('plugin_install_click', { input_value: sanitizeInstallInput(spec), plugin_type: 'bundle' }) + this.ctx.get('productAnalytics')?.track('plugin_install_click', { input_value: sanitizeInstallInput(spec) }) } // A name the list already shows is refused at once, before the Host is asked. if (state.packages.some(pkg => pkg.name === spec)) { @@ -1094,14 +1094,15 @@ export class PluginManagerController { }) } - private finishAnalytics(status: ProductEventMap['install_plugin_result']['result_status'], reason?: string, name?: string): void { + private finishAnalytics(status: 'success' | 'failed' | 'cancelled' | 'unknown', reason?: string, name?: string): void { const attempt = this.analyticsAttempt this.analyticsAttempt = undefined if (attempt === undefined || this.disposed) return + const errorReason = status === 'cancelled' ? 'user_cancelled' : status === 'unknown' ? 'unknown_result' : reason this.ctx.get('productAnalytics')?.track('install_plugin_result', { - input_value: attempt.input, plugin_type: 'bundle', is_success: status === 'success', result_status: status, + input_value: attempt.input, is_success: status === 'success', duration: Math.max(0, Date.now() - attempt.started), - ...reason === undefined ? {} : { error_reason: reason }, ...name === undefined ? {} : { plugin_name: name }, + ...errorReason === undefined ? {} : { error_reason: errorReason }, ...name === undefined ? {} : { plugin_name: name }, }) } diff --git a/packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts b/packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts index 2eec275f4c..1169a9e444 100644 --- a/packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts +++ b/packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts @@ -313,7 +313,7 @@ describe('PluginManagerController', () => { }) it('always asks before uninstalling, and cancelling runs nothing', async () => { - const { plugins, face, state, controller } = bench() + const { plugins, face, state, controller, track } = bench() await controller.load() face.uninstall(BUNDLE.name) expect(state().confirm).toEqual({ action: 'uninstall', packageName: BUNDLE.name }) @@ -321,10 +321,12 @@ describe('PluginManagerController', () => { expect(state().confirm).toBeNull() face.confirm() expect(plugins.removeBundle).not.toHaveBeenCalled() + expect(track).not.toHaveBeenCalled() face.uninstall(BUNDLE.name) face.confirm() expect(state().confirm).toBeNull() await vi.waitFor(() => { expect(plugins.removeBundle).toHaveBeenCalledExactlyOnceWith(BUNDLE.name) }) + expect(track).toHaveBeenCalledExactlyOnceWith('confirm_uninstall_plugin', { plugin_name: BUNDLE.name }) await vi.waitFor(() => { expect(state().busy).toEqual([]) }) // A refused removal names the action it was. plugins.removeBundle.mockResolvedValueOnce(ok({ ...failed(), stage: 'remove', error: { code: 'not-removable' } }) as never) @@ -1423,7 +1425,7 @@ describe('desktop analytics outcomes', () => { b.face.editInstallSpec('git+https://user:private-token@example.invalid/repo.git?token=private-token') b.face.runInstall() await vi.waitFor(() => { expect(b.track).toHaveBeenCalledWith('install_plugin_result', expect.objectContaining({ input_value: '[git]' })) }) - expect(b.track).toHaveBeenCalledWith('plugin_install_click', { input_value: '[git]', plugin_type: 'bundle' }) + expect(b.track).toHaveBeenCalledWith('plugin_install_click', { input_value: '[git]' }) expect(JSON.stringify(b.track.mock.calls)).not.toContain('private-token') }) @@ -1432,8 +1434,8 @@ describe('desktop analytics outcomes', () => { b.face.openInstall() b.face.editInstallSpec('dsh-new') b.face.runInstall() - await vi.waitFor(() => { expect(b.track).toHaveBeenCalledWith('install_plugin_result', expect.objectContaining({ input_value: 'dsh-new', result_status: 'failed', is_success: false, error_reason: 'not-bundle', duration: expect.any(Number) as number })) }) - expect(b.track).toHaveBeenCalledWith('plugin_install_click', { input_value: 'dsh-new', plugin_type: 'bundle' }) + await vi.waitFor(() => { expect(b.track).toHaveBeenCalledWith('install_plugin_result', expect.objectContaining({ input_value: 'dsh-new', is_success: false, error_reason: 'not-bundle', duration: expect.any(Number) as number })) }) + expect(b.track).toHaveBeenCalledWith('plugin_install_click', { input_value: 'dsh-new' }) }) it('reports unknown only after recovery confirms the result is absent', async () => { @@ -1445,12 +1447,12 @@ describe('desktop analytics outcomes', () => { await vi.waitFor(() => { expect(b.plugins.waitForInstall).toHaveBeenCalledTimes(1) }) expect(b.track.mock.calls.filter(call => call[0] === 'install_plugin_result')).toEqual([]) lost.resolve(ok(null)) - await vi.waitFor(() => { expect(b.track).toHaveBeenCalledWith('install_plugin_result', expect.objectContaining({ result_status: 'unknown', is_success: false })) }) + await vi.waitFor(() => { expect(b.track).toHaveBeenCalledWith('install_plugin_result', expect.objectContaining({ error_reason: 'unknown_result', is_success: false })) }) b.face.reconcileInstall() expect(b.track.mock.calls.filter(call => call[0] === 'install_plugin_result')).toHaveLength(1) }) - it('closing during inspection reports cancellation once without an error reason', async () => { + it('closing during inspection reports user_cancelled once', async () => { const inspecting = deferred>>() const b = bench({ inspect: vi.fn(() => inspecting.promise) }) b.face.openInstall() @@ -1462,8 +1464,9 @@ describe('desktop analytics outcomes', () => { await Promise.resolve() const results = b.track.mock.calls.filter(call => call[0] === 'install_plugin_result') expect(results).toHaveLength(1) - expect(results[0]?.[1]).toMatchObject({ result_status: 'cancelled', is_success: false }) - expect(results[0]?.[1]).not.toHaveProperty('error_reason') + expect(results[0]?.[1]).toMatchObject({ error_reason: 'user_cancelled', is_success: false }) + expect(results[0]?.[1]).not.toHaveProperty('result_status') + expect(results[0]?.[1]).not.toHaveProperty('plugin_type') expect(b.plugins.installBundle).not.toHaveBeenCalled() }) @@ -1500,3 +1503,15 @@ it('reports restart-required row toggles and tolerates a vanished inventory entr await vi.waitFor(() => { expect(b.state().busy).toEqual([]) }) expect(b.track.mock.calls.filter(([name]) => name === 'plugin_toggle')).toHaveLength(1) }) + +it('reports a successful install with only documented result fields', async () => { + const b = bench() + b.face.openInstall() + b.face.editInstallSpec('dsh-new') + b.face.runInstall() + await vi.waitFor(() => { expect(b.state().install.phase).toBe('done') }) + const results = b.track.mock.calls.filter(([name]) => name === 'install_plugin_result') + expect(results).toEqual([['install_plugin_result', { + input_value: 'dsh-new', is_success: true, duration: expect.any(Number) as number, plugin_name: 'dsh-new', + }]]) +}) diff --git a/packages/client/ui-settings-account/src/client/DesktopOnboarding.tsx b/packages/client/ui-settings-account/src/client/DesktopOnboarding.tsx index 05b4110b10..99c1bc4d2a 100644 --- a/packages/client/ui-settings-account/src/client/DesktopOnboarding.tsx +++ b/packages/client/ui-settings-account/src/client/DesktopOnboarding.tsx @@ -106,7 +106,7 @@ export function DesktopOnboarding({
{step === 'welcome' && { click('next'); go('credit') }} />} {step === 'credit' && { click('next'); go('purpose') }} onRecharge={() => { click('charge'); recharge() }} onLater={() => { click('later'); later() }} />} + funded={state.creditFunded} canRecharge={canRecharge} onContinue={() => { click('continue'); go('purpose') }} onRecharge={() => { click('charge'); recharge() }} onLater={() => { click('later'); later() }} />} {step === 'purpose' && { void update({ purpose }) }} onContinue={() => { click('next'); purposeNext() }} />} {step === 'process' && {dialog !== null && { setDialog(null) }} onContinueSetup={() => { popupClick('setting'); setDialog(null) }} onContinue={() => { popupClick('know'); setDialog(null); go('purpose') }} onRecharge={() => { popupClick('charge'); recharge() }} + onClose={() => { popupClick('close'); setDialog(null) }} onContinueSetup={() => { popupClick('setting'); setDialog(null) }} onContinue={() => { popupClick('know'); setDialog(null); go('purpose') }} onRecharge={() => { popupClick('charge'); recharge() }} onSkip={() => { popupClick(dialog === 'skip' ? 'enter' : 'know'); void complete('skipped').then(() => { setDialog(null) }) }} />} } diff --git a/packages/client/ui-settings-account/tests/desktop-onboarding.client.spec.tsx b/packages/client/ui-settings-account/tests/desktop-onboarding.client.spec.tsx index 1ee7324909..8152510505 100644 --- a/packages/client/ui-settings-account/tests/desktop-onboarding.client.spec.tsx +++ b/packages/client/ui-settings-account/tests/desktop-onboarding.client.spec.tsx @@ -335,16 +335,16 @@ it('returns from purpose to credit without dropping the selected purpose', async }) -it('reports visible pages once, funded Continue, popup actions, and no close event', async () => { +it('reports visible pages once, funded Continue, and popup close', async () => { const b = mount('credit', 'positive', 'ready', zh, true) expect(b.track).toHaveBeenCalledExactlyOnceWith('onboarding_page_view', { page_name: 'onboarding_recharge' }) await act(async () => { fireEvent.click(screen.getByRole('button', { name: zh.onboardingSkip })) }) expect(b.track).toHaveBeenCalledWith('onboarding_popup_view', { popup_name: 'skip_charge' }) b.track.mockClear() await act(async () => { fireEvent.click(screen.getByRole('button', { name: zh.close })) }) - expect(b.track).not.toHaveBeenCalled() + expect(b.track).toHaveBeenCalledExactlyOnceWith('onboarding_popup_click', { popup_name: 'skip_charge', button_name: 'close' }) await act(async () => { fireEvent.click(screen.getByRole('button', { name: zh.onboardingContinue })) }) - expect(b.track).toHaveBeenCalledWith('onboarding_page_click', { page_name: 'onboarding_recharge', button_name: 'next' }) + expect(b.track).toHaveBeenCalledWith('onboarding_page_click', { page_name: 'onboarding_recharge', button_name: 'continue' }) expect(b.track).toHaveBeenCalledWith('onboarding_page_view', { page_name: 'onboarding_use_case' }) }) @@ -361,3 +361,11 @@ it('does not repeat page or popup exposure during save and refresh', () => { act(() => { b.changeStatus('ready') }) expect(b.track.mock.calls.filter(([name]) => name === 'onboarding_page_view')).toHaveLength(1) }) + +it('reports closing the skip-settings popup', () => { + const b = mount('purpose') + fireEvent.click(screen.getByRole('button', { name: zh.onboardingSkip })) + b.track.mockClear() + fireEvent.click(screen.getByRole('button', { name: zh.close })) + expect(b.track).toHaveBeenCalledExactlyOnceWith('onboarding_popup_click', { popup_name: 'skip_setting', button_name: 'close' }) +}) diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index a17df31798..cdfd82df01 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -6005,7 +6005,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ProductEventMap', - declaration: 'export interface ProductEventMap {\n desktop_app_launch: Record;\n auth_page_view: Record;\n auth_page_click: {\n button_name: \'sign_in\' | \'api-key\';\n };\n api_key_save_click: Record;\n onboarding_page_view: {\n page_name: OnboardingPage;\n };\n onboarding_page_click: {\n page_name: OnboardingPage;\n button_name: \'next\' | \'back\' | \'skip\' | \'charge\' | \'later\';\n selected_content?: \'office\' | \'code\' | \'code_office\' | \'focus_result\' | \'key_detail\' | \'full_process\';\n };\n onboarding_popup_view: {\n popup_name: \'skip_charge\' | \'skip_setting\';\n };\n onboarding_popup_click: {\n popup_name: \'skip_charge\' | \'skip_setting\';\n button_name: \'charge\' | \'know\' | \'enter\' | \'setting\';\n };\n desktop_upgrade_click: Record;\n desktop_upgrade_download_result: {\n is_success: boolean;\n error_reason?: string;\n };\n desktop_upgrade_install_restart_click: Record;\n send_button_click: {\n session_id?: SessionId;\n model_name?: string;\n thinking_effort?: string;\n run_mode: \'plan\' | \'goal\' | \'default\';\n submit_source?: \'click\' | \'enter\';\n submit_type: \'normal\' | \'steer\' | \'queue\';\n };\n model_switch: {\n session_id?: SessionId;\n switch_from: string;\n switch_to: string;\n };\n thinking_level_switch: {\n session_id?: SessionId;\n switch_from: string;\n /* …truncated — full shape in source */', + declaration: 'export interface ProductEventMap {\n desktop_app_launch: Record;\n auth_page_view: Record;\n auth_page_click: {\n button_name: \'sign_in\' | \'api-key\';\n };\n api_key_save_click: Record;\n onboarding_page_view: {\n page_name: OnboardingPage;\n };\n onboarding_page_click: {\n page_name: OnboardingPage;\n button_name: \'next\' | \'back\' | \'skip\' | \'charge\' | \'later\' | \'continue\';\n selected_content?: \'office\' | \'code\' | \'code_office\' | \'focus_result\' | \'key_detail\' | \'full_process\';\n };\n onboarding_popup_view: {\n popup_name: \'skip_charge\' | \'skip_setting\';\n };\n onboarding_popup_click: {\n popup_name: \'skip_charge\' | \'skip_setting\';\n button_name: \'charge\' | \'know\' | \'enter\' | \'setting\' | \'close\';\n };\n desktop_upgrade_click: Record;\n desktop_upgrade_download_result: {\n is_success: boolean;\n error_reason?: string;\n };\n desktop_upgrade_install_restart_click: Record;\n send_button_click: {\n session_id?: SessionId;\n model_name?: string;\n thinking_effort?: string;\n run_mode: \'plan\' | \'goal\' | \'default\';\n msg_type: \'default\' | \'steer\' | \'queue\';\n };\n model_switch: {\n session_id?: SessionId;\n switch_from: string;\n switch_to: string;\n };\n thinking_level_switch: {\n session_id?: SessionId;\n switch_from: string;\n switch_to: str /* …truncated — full shape in source */', }, { name: 'ProductTelemetryRecord',