fix(analytics): align desktop event fields with product specification

This commit is contained in:
zhc committed 2026-09-26 00:18:07 +08:00
1 parent 6c3df458c5
commit 3fc7cc6607
14 files changed
+89 -47

No files matched your search

@@ -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
+2 -2
View File
@@ -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`.
<a id="understand-the-implementation"></a>
## Understand the implementation
@@ -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`。
<a id="understand-the-implementation"></a>
## 了解实现
@@ -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<string, never>
desktop_upgrade_download_result: { is_success: boolean; error_reason?: string }
desktop_upgrade_install_restart_click: Record<string, never>
@@ -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<string, never>
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. */
@@ -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)
}
@@ -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() }
})
@@ -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<unknown>,
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 })
@@ -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)
@@ -138,7 +138,11 @@ async function bench(locale: 'zh' | 'en' = 'zh') {
const scopes = new Map<SessionId, Context>()
const bindings = new Map<SessionId, {
sessionId: SessionId
session: { sessionId: SessionId; projections: { faceOf: () => SnapshotStore<ModelSelectionProjection | undefined> } }
session: {
sessionId: SessionId
getSnapshot: () => { blank: boolean }
projections: { faceOf: () => SnapshotStore<ModelSelectionProjection | undefined> }
}
ctx: Context
}>()
const addressed = new Set<SessionId>()
@@ -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()
@@ -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 },
})
}
@@ -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:[email protected]/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<ReturnType<typeof ok<typeof INSPECTED>>>()
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',
}]])
})
@@ -106,7 +106,7 @@ export function DesktopOnboarding({
<section key={step} ref={page} className={`${css.page} ${dialog !== null ? css.blurred : ''}`} data-desktop-onboarding={step} lang={locale} aria-labelledby="desktop-onboarding-title" aria-busy={busy}>
{step === 'welcome' && <OnboardingWelcomeStep t={t} locale={locale} heading={heading} busy={busy} onStart={() => { click('next'); go('credit') }} />}
{step === 'credit' && <OnboardingCreditStep t={t} locale={locale} heading={heading} busy={busy}
funded={state.creditFunded} canRecharge={canRecharge} onContinue={() => { 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' && <OnboardingPurposeStep t={t} heading={heading} busy={busy} purpose={progress.purpose}
onSelect={(purpose) => { void update({ purpose }) }} onContinue={() => { click('next'); purposeNext() }} />}
{step === 'process' && <OnboardingProcessStep t={t} heading={heading} busy={busy} process={progress.process}
@@ -119,7 +119,7 @@ export function DesktopOnboarding({
</section>
</OnboardingSurface>
{dialog !== null && <OnboardingConfirmation kind={dialog} t={t} busy={busy} canRecharge={canRecharge}
onClose={() => { 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) }) }} />}
</>
}
@@ -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' })
})
@@ -6005,7 +6005,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'ProductEventMap',
declaration: 'export interface ProductEventMap {\n desktop_app_launch: Record<string, never>;\n auth_page_view: Record<string, never>;\n auth_page_click: {\n button_name: \'sign_in\' | \'api-key\';\n };\n api_key_save_click: Record<string, never>;\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<string, never>;\n desktop_upgrade_download_result: {\n is_success: boolean;\n error_reason?: string;\n };\n desktop_upgrade_install_restart_click: Record<string, never>;\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<string, never>;\n auth_page_view: Record<string, never>;\n auth_page_click: {\n button_name: \'sign_in\' | \'api-key\';\n };\n api_key_save_click: Record<string, never>;\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<string, never>;\n desktop_upgrade_download_result: {\n is_success: boolean;\n error_reason?: string;\n };\n desktop_upgrade_install_restart_click: Record<string, never>;\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',