refactor(analytics): limit auth events and reuse client version
This commit is contained in:
1 parent
97b3e97657
commit
5d1beb324e
22 files changed
+70
-66
No files matched your search
@@ -67,10 +67,13 @@ export interface DesktopAccountBackend {
|
||||
* @param listener - state recipient.
|
||||
* @param failed - stream failure recipient.
|
||||
* @param expired - live credential-expiry recipient.
|
||||
* @param policy - optional Desktop collection-policy recipient; disconnected streams publish false.
|
||||
* @param onAnalyticsEnabledChanged - optional Desktop collection-policy recipient; disconnected streams publish false.
|
||||
* @returns stream disposer.
|
||||
*/
|
||||
watch(listener: (state: AccountView) => void, failed: () => void, expired: () => void, policy?: (enabled: boolean) => void): () => void
|
||||
watch(
|
||||
listener: (state: AccountView) => void, failed: () => void, expired: () => void,
|
||||
onAnalyticsEnabledChanged?: (enabled: boolean) => void,
|
||||
): () => void
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -87,21 +90,21 @@ export function desktopAccountBackend(origin: string, invoke: AccountInvoke, coo
|
||||
state: () => call('getState'),
|
||||
start: client => call('startSignIn', { client, callbackOrigin: new URL(origin).origin, loginSource: 'desktop' }),
|
||||
cancel: attemptId => call('cancelSignIn', { attemptId }), signOut: client => call('signOut', { client }),
|
||||
watch(listener, failed, expired, policy) {
|
||||
watch(listener, failed, expired, onAnalyticsEnabledChanged) {
|
||||
let closed = false
|
||||
let socket: WebSocket | undefined
|
||||
let retry: ReturnType<typeof setTimeout> | undefined
|
||||
const connect = (): void => {
|
||||
const streamId = randomUUID()
|
||||
const expiryStreamId = randomUUID()
|
||||
const policyStreamId = randomUUID()
|
||||
const analyticsPolicyStreamId = randomUUID()
|
||||
void cookies().then((cookie) => {
|
||||
if (closed) return
|
||||
const url = new URL(REMOTE_STREAM_MUX_PATH, origin)
|
||||
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
socket = new WebSocket(url, { headers: { cookie, origin }, maxPayload: 65_536 })
|
||||
socket.on('open', () => {
|
||||
if (policy !== undefined) socket?.send(JSON.stringify({ type: 'open', streamId: policyStreamId, endpoint: 'productAnalytics/watchPolicy', payload: { args: {} } }))
|
||||
if (onAnalyticsEnabledChanged !== undefined) socket?.send(JSON.stringify({ type: 'open', streamId: analyticsPolicyStreamId, endpoint: 'productAnalytics/watchPolicy', payload: { args: {} } }))
|
||||
socket?.send(JSON.stringify({ type: 'open', streamId: expiryStreamId, endpoint: 'account/watchExpiry', payload: { args: {} } }))
|
||||
socket?.send(JSON.stringify({ type: 'open', streamId, endpoint: 'account/watch', payload: { args: {} } }))
|
||||
})
|
||||
@@ -109,9 +112,9 @@ export function desktopAccountBackend(origin: string, invoke: AccountInvoke, coo
|
||||
try {
|
||||
const bytes = Array.isArray(data) ? Buffer.concat(data) : Buffer.isBuffer(data) ? data : Buffer.from(data)
|
||||
const frame = parseRemoteStreamServerMessage(bytes.toString('utf8'))
|
||||
if (frame.streamId === policyStreamId) {
|
||||
if (frame.type === 'item' && typeof frame.value === 'boolean') policy?.(frame.value)
|
||||
else policy?.(false)
|
||||
if (frame.streamId === analyticsPolicyStreamId) {
|
||||
if (frame.type === 'item' && typeof frame.value === 'boolean') onAnalyticsEnabledChanged?.(frame.value)
|
||||
else onAnalyticsEnabledChanged?.(false)
|
||||
return
|
||||
}
|
||||
if (frame.streamId === expiryStreamId && frame.type === 'item' && frame.value === 'session-expired') { expired(); return }
|
||||
@@ -121,11 +124,11 @@ export function desktopAccountBackend(origin: string, invoke: AccountInvoke, coo
|
||||
} catch { socket?.close() }
|
||||
})
|
||||
socket.on('error', () => { socket?.close() })
|
||||
socket.on('close', () => { if (!closed) { policy?.(false); failed(); retry = setTimeout(connect, 1000) } })
|
||||
}).catch(() => { if (!closed) { policy?.(false); failed(); retry = setTimeout(connect, 1000) } })
|
||||
socket.on('close', () => { if (!closed) { onAnalyticsEnabledChanged?.(false); failed(); retry = setTimeout(connect, 1000) } })
|
||||
}).catch(() => { if (!closed) { onAnalyticsEnabledChanged?.(false); failed(); retry = setTimeout(connect, 1000) } })
|
||||
}
|
||||
connect()
|
||||
return () => { closed = true; policy?.(false); clearTimeout(retry); socket?.close() }
|
||||
return () => { closed = true; onAnalyticsEnabledChanged?.(false); clearTimeout(retry); socket?.close() }
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -45,7 +45,7 @@ import { DesktopUpdateSchedule, resolveDesktopUpdateScheduleConfig } from './upd
|
||||
import { desktopUpdateErrorSummary, presentDesktopUpdate } from './update-presentation.ts'
|
||||
import { desktopErrorState } from './startup-error.ts'
|
||||
import { DesktopMandatoryUpdatePolicy, resolveDesktopPolicyConfig, type DesktopPolicyState } from './mandatory-update-policy.ts'
|
||||
import { desktopClientMetadata } from './client-metadata.ts'
|
||||
import { desktopClientMetadata, desktopClientVersion } from './client-metadata.ts'
|
||||
import { DesktopMandatoryUpdateWindow } from './mandatory-update-window.ts'
|
||||
import { DesktopPolicyTestAuth } from './policy-test-auth.ts'
|
||||
import { DesktopUpdateDialog, type UpdateDialogOptions } from './update-dialog.ts'
|
||||
@@ -417,7 +417,7 @@ async function main(): Promise<void> {
|
||||
const backend = new DesktopBackendController((onFailure) => {
|
||||
const hostInspectPort = developmentHostInspectPort(development)
|
||||
const host = new DesktopHostProcess(resources.node, resources.dsh, activeProject,
|
||||
hostInspectPort, { ...process.env, DSH_PRODUCT_ANALYTICS_APP_VERSION: app.getVersion() }, onFailure,
|
||||
hostInspectPort, { ...process.env, DSH_CLIENT_VERSION: desktopClientVersion() }, onFailure,
|
||||
primaryRuntime,
|
||||
resources, (next) => { platformView.setSession(next) })
|
||||
return {
|
||||
|
||||
@@ -49,12 +49,14 @@ it('receives live expiry separately from account snapshots', async () => {
|
||||
if (typeof address === 'string' || address === null) throw new Error('missing server address')
|
||||
const connected = once(server, 'connection')
|
||||
const expired = vi.fn()
|
||||
const policy = vi.fn()
|
||||
const onAnalyticsEnabledChanged = vi.fn()
|
||||
const snapshot = { status: 'signed-out', attempt: null,
|
||||
links: { usageUrl: 'https://platform.deepseek.com/usage', topUpUrl: 'https://platform.deepseek.com/top_up' } }
|
||||
const received = Promise.withResolvers<undefined>()
|
||||
const backend = desktopAccountBackend(`http://127.0.0.1:${address.port}`, () => Promise.resolve(snapshot), () => Promise.resolve(''))
|
||||
const stop = backend.watch((value) => { expect(value).toEqual(snapshot); received.resolve(undefined) }, vi.fn(), expired, policy)
|
||||
const stop = backend.watch(
|
||||
(value) => { expect(value).toEqual(snapshot); received.resolve(undefined) }, vi.fn(), expired, onAnalyticsEnabledChanged,
|
||||
)
|
||||
onTestFinished(stop)
|
||||
await connected
|
||||
// Logical streams share the socket; observe their opening frames before sending data.
|
||||
@@ -71,13 +73,13 @@ it('receives live expiry separately from account snapshots', async () => {
|
||||
socket.send(JSON.stringify({ type: 'item', streamId: streams.get('account/watch'), value: snapshot }))
|
||||
await received.promise
|
||||
socket.send(JSON.stringify({ type: 'item', streamId: streams.get('productAnalytics/watchPolicy'), value: true }))
|
||||
await vi.waitFor(() => { expect(policy).toHaveBeenLastCalledWith(true) })
|
||||
await vi.waitFor(() => { expect(onAnalyticsEnabledChanged).toHaveBeenLastCalledWith(true) })
|
||||
socket.send(JSON.stringify({ type: 'item', streamId: streams.get('productAnalytics/watchPolicy'), value: false }))
|
||||
await vi.waitFor(() => { expect(policy).toHaveBeenLastCalledWith(false) })
|
||||
await vi.waitFor(() => { expect(onAnalyticsEnabledChanged).toHaveBeenLastCalledWith(false) })
|
||||
socket.send(JSON.stringify({ type: 'item', streamId: streams.get('productAnalytics/watchPolicy'), value: true }))
|
||||
await vi.waitFor(() => { expect(policy).toHaveBeenLastCalledWith(true) })
|
||||
await vi.waitFor(() => { expect(onAnalyticsEnabledChanged).toHaveBeenLastCalledWith(true) })
|
||||
socket.send(JSON.stringify({ type: 'item', streamId: streams.get('productAnalytics/watchPolicy'), value: 'invalid policy' }))
|
||||
await vi.waitFor(() => { expect(policy).toHaveBeenLastCalledWith(false) })
|
||||
await vi.waitFor(() => { expect(onAnalyticsEnabledChanged).toHaveBeenLastCalledWith(false) })
|
||||
expect(expired).not.toHaveBeenCalled()
|
||||
socket.send(JSON.stringify({ type: 'item', streamId: streams.get('account/watchExpiry'), value: 'session-expired' }))
|
||||
await vi.waitFor(() => { expect(expired).toHaveBeenCalledOnce() })
|
||||
|
||||
@@ -173,7 +173,7 @@ const harness = await vi.hoisted(async () => {
|
||||
})
|
||||
let accountListener: ((state: AccountView) => void) | undefined
|
||||
let analyticsEnabled = true
|
||||
let policyListener: ((enabled: boolean) => void) | undefined
|
||||
let analyticsEnabledListener: ((enabled: boolean) => void) | undefined
|
||||
const analytics = vi.fn(async (_event: unknown) => {})
|
||||
const nativeTheme = { themeSource: 'system', shouldUseDarkColors: false }
|
||||
const trays: FakeTray[] = []
|
||||
@@ -194,13 +194,14 @@ const harness = await vi.hoisted(async () => {
|
||||
platformCloseAndWait,
|
||||
|
||||
get analyticsEnabled() { return analyticsEnabled },
|
||||
set analyticsEnabled(value: boolean) { analyticsEnabled = value; policyListener?.(value) },
|
||||
set analyticsEnabled(value: boolean) { analyticsEnabled = value; analyticsEnabledListener?.(value) },
|
||||
watchAccount: (
|
||||
listener: (state: AccountView) => void, _failed: () => void, _expired: () => void, policy?: (enabled: boolean) => void,
|
||||
listener: (state: AccountView) => void, _failed: () => void, _expired: () => void,
|
||||
onAnalyticsEnabledChanged?: (enabled: boolean) => void,
|
||||
) => {
|
||||
policyListener = policy
|
||||
analyticsEnabledListener = onAnalyticsEnabledChanged
|
||||
accountListener = listener
|
||||
return () => { accountListener = undefined; policyListener = undefined }
|
||||
return () => { accountListener = undefined; analyticsEnabledListener = undefined }
|
||||
},
|
||||
publishAccount(state: AccountView) { accountListener?.(state) },
|
||||
ipcOn: vi.fn<(channel: string, listener: (event: { sender: unknown; senderFrame: unknown }, ...args: unknown[]) => void) => void>(),
|
||||
@@ -2064,7 +2065,7 @@ describe('desktop main startup', () => {
|
||||
profile: 'desktop-test-profile',
|
||||
})
|
||||
expect(harness.hosts[0]!.environment).not.toBe(process.env)
|
||||
expect(harness.hosts[0]!.environment?.DSH_PRODUCT_ANALYTICS_APP_VERSION).toBe('1.0.0')
|
||||
expect(harness.hosts[0]!.environment?.DSH_CLIENT_VERSION).toBe('1.2.3')
|
||||
expect(harness.analytics).toHaveBeenCalledExactlyOnceWith({ eventName: 'desktop_app_launch', timestamp: Date.now(), attributes: {} })
|
||||
expect(harness.hosts[0]!.start).toHaveBeenCalledTimes(1)
|
||||
expect(harness.windows).toHaveLength(1)
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
exportTimeoutMillis: 1500
|
||||
shutdownTimeoutMillis: 2000
|
||||
serviceName: deepseek-harness-desktop
|
||||
serviceVersion: !!js process.env.DSH_PRODUCT_ANALYTICS_APP_VERSION
|
||||
serviceVersion: !!js process.env.DSH_CLIENT_VERSION
|
||||
endpoint: !!js process.env.DSH_PRODUCT_ANALYTICS_OTLP_URL
|
||||
|
||||
- id: product-analytics
|
||||
@@ -58,7 +58,7 @@
|
||||
disabled: !!js "ctx.get('profileContext')?.name !== 'desktop'"
|
||||
config:
|
||||
enabled: true
|
||||
appVersion: !!js process.env.DSH_PRODUCT_ANALYTICS_APP_VERSION
|
||||
appVersion: !!js process.env.DSH_CLIENT_VERSION
|
||||
|
||||
# Host-owned opt-in sampled when a new Web session receives its preset
|
||||
# delegation tools. The Plugins page edits this settings namespace.
|
||||
|
||||
@@ -20,7 +20,7 @@ it.each(['desktop', 'web'])('limits collection and its shutdown to the Desktop l
|
||||
onTestFinished(async () => { const closed = once(server, 'close'); server.close(); server.closeAllConnections(); await closed })
|
||||
const address = server.address()
|
||||
if (address === null || typeof address === 'string') throw new Error('missing collector address')
|
||||
vi.stubEnv('DSH_PRODUCT_ANALYTICS_APP_VERSION', 'test-version')
|
||||
vi.stubEnv('DSH_CLIENT_VERSION', 'test-version')
|
||||
vi.stubEnv('DSH_PRODUCT_ANALYTICS_OTLP_URL', `http://127.0.0.1:${address.port}/logs`)
|
||||
const ctx = new Context()
|
||||
onTestFinished(() => ctx.fiber.dispose())
|
||||
@@ -68,6 +68,9 @@ it.each(['desktop', 'web'])('limits collection and its shutdown to the Desktop l
|
||||
expect(identity).not.toHaveBeenCalled()
|
||||
}
|
||||
}
|
||||
const emit = vi.spyOn(ctx.productTelemetry, 'emit')
|
||||
await analytics.report({ eventName: 'desktop_app_launch', timestamp: 1, attributes: {} })
|
||||
expect(emit).toHaveBeenCalledWith(expect.objectContaining({ attributes: { app_version: 'test-version' } }))
|
||||
lifetime.abort()
|
||||
await policy.return?.()
|
||||
ctx.productTelemetry.emit({ eventName: 'desktop_upgrade_install_restart_click', body: 'upgrade', timestamp: Date.now() })
|
||||
|
||||
@@ -15,8 +15,8 @@
|
||||
en: 2fee9973f19f9401
|
||||
zh: 8ab4b55413cb8408
|
||||
/deepseek-ai-dsh-client-product-analytics/use-this-package:
|
||||
en: c136c4af1b72bdcb
|
||||
zh: 8e287dfed42cea82
|
||||
en: c8c05edf30b3959d
|
||||
zh: 39c2147cf01d43b5
|
||||
/deepseek-ai-dsh-client-product-analytics/understand-the-implementation:
|
||||
en: 416b7e925f0852c9
|
||||
zh: c0b83bacf18e91b1
|
||||
|
||||
@@ -27,9 +27,9 @@ The renderer and Electron subscribe to the Host policy through the existing auth
|
||||
|
||||
Common fields are `device_id`, `user_id`, `os_version`, and `app_version`. Device identity reuses the existing login record without generating one. The Host reads credential-free device, account, and OS fields through `deepseekAccount.getDeviceIdentity()`. Missing values are omitted; API keys, account tokens, prompts, and responses are never event fields.
|
||||
|
||||
Electron supplies internal `DSH_PRODUCT_ANALYTICS_APP_VERSION` from its running version; the exporter requires it. This variable belongs to the Desktop launcher-to-Host protocol. Desktop export, batch, and shutdown deadlines are 1000, 1500, and 2000 milliseconds, below the Host exit budget.
|
||||
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. 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 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.
|
||||
|
||||
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`.
|
||||
|
||||
|
||||
@@ -27,9 +27,9 @@ kind: "package-reference"
|
||||
|
||||
公共字段为 `device_id`、`user_id`、`os_version` 和 `app_version`。设备身份复用现有登录记录,不会生成新标识。Host 通过 `deepseekAccount.getDeviceIdentity()` 读取不含凭据的设备、账户和操作系统字段。缺失值会省略;API key、账户令牌、提示词和模型回复都不是事件字段。
|
||||
|
||||
Electron 通过内部变量 `DSH_PRODUCT_ANALYTICS_APP_VERSION` 传递运行版本;导出器要求该值存在。该变量属于桌面启动器与 Host 之间的协议。桌面导出、批次与关闭期限分别为 1000、1500 和 2000 毫秒,均短于 Host 退出预算。
|
||||
Electron 将构建内联的 `DSH_CLIENT_VERSION` 传给 Host;埋点与导出器复用这一客户端版本,导出器要求该值存在。桌面导出、批次与关闭期限分别为 1000、1500 和 2000 毫秒,均短于 Host 退出预算。
|
||||
|
||||
[事件类型](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 弹窗不产生事件。有余额时的继续按钮使用 `next`。消息提交保留最初发生时间,并在异步命令裁决前采集 `submit_source`、`submit_type`、模型、显式思考强度和 `run_mode`;仅普通消息路径在引用序列化之前上报,已处理或认领的命令不计入。后续序列化、附件处理或发送失败不撤销此次计数,排队后的执行也不重复计数;纯附件提交遵循相同规则。程序化提交省略 `submit_source`;键盘与按钮操作分别使用 `enter` 和 `click`。计划模式优先于活跃目标。采集和上报异常不会中断提交。首次发送的空白会话省略 `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`。
|
||||
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
en: 1fcba8f273b661fb
|
||||
zh: 14e1236f84ae39d7
|
||||
/deepseek-ai-dsh-client-ui-settings-account:
|
||||
en: 311be03fcfd8ccb8
|
||||
zh: e65daffe9234c40a
|
||||
en: 9ff7ac2d70f70b59
|
||||
zh: ded522c4cfd6e040
|
||||
/deepseek-ai-dsh-client-ui-settings-account/summary:
|
||||
en: 9ff357621753be69
|
||||
zh: 3f7ede82acc9fa31
|
||||
|
||||
@@ -7,7 +7,7 @@ kind: "package-reference"
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Desktop product events use the optional [product analytics service](../product-analytics/README.md); ordinary Web interactions are excluded.
|
||||
Desktop product events use the optional [product analytics service](../product-analytics/README.md); ordinary Web interactions are excluded. Workspace login dialogs emit no authentication events; native welcome owns authentication analytics.
|
||||
|
||||
Server-expired account credentials clear the displayed account details and emit a localized sign-in reminder toast once; account snapshot replay does not repeat it.
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ kind: "package-reference"
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
桌面端产品事件使用可选的[产品埋点服务](../product-analytics/README.zh.md),不包含普通 Web 交互。
|
||||
桌面端产品事件使用可选的[产品埋点服务](../product-analytics/README.zh.md),不包含普通 Web 交互。工作区内的登录弹窗不发送认证事件;认证埋点由原生欢迎页负责。
|
||||
|
||||
账号凭据被服务端判定失效后,清空显示的账号详情,并弹出一次本地化的重新登录提示 toast;账号快照重放不会重复提示。
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ export type AccountMenuProps = PropsRuntime<'settings.launcher'> & PropsLocale<'
|
||||
export function AccountMenu({
|
||||
subscribeSessionExpired, subscribeModelSignInRequired, wide, settingsShortcut, openSettings, openOnboarding, settingsOpen,
|
||||
useAccount, useTheme, signOut, hasRunningAccountTasks, refreshAccount, bonusNoticeShown, bonusNoticeDismissed,
|
||||
contactUs, showLogin, start, cancel, t, track,
|
||||
contactUs, showLogin, start, cancel, t,
|
||||
}: AccountMenuProps) {
|
||||
const anchor = useRef<HTMLDivElement>(null)
|
||||
// The launcher outlives the panel, so a false-to-true edge is one Settings entry:
|
||||
@@ -82,7 +82,7 @@ export function AccountMenu({
|
||||
else if (id === 'signin') beginSignIn()
|
||||
else void requestSignOut()
|
||||
}} />
|
||||
{account.loginVisible && !account.onboarding && <SignInDialog track={track} account={account} colorScheme={colorScheme}
|
||||
{account.loginVisible && !account.onboarding && <SignInDialog account={account} colorScheme={colorScheme}
|
||||
start={start} cancel={cancel} t={t}
|
||||
close={() => { showLogin(false) }} useApiKey={() => { showLogin(false); openOnboarding('deepseek-official') }} />}
|
||||
{signedIn && signOutImpact !== undefined && <SignOutDialog running={signOutImpact} signOut={signOut}
|
||||
|
||||
@@ -6,13 +6,13 @@ import type { AccountSectionInjected } from './AccountSection.tsx'
|
||||
import { SignInDialog } from './SignInDialog.tsx'
|
||||
|
||||
/** @param props - onboarding completion, account state and actions. @returns account dialog while signed out. */
|
||||
export function AccountOnboarding({ complete, useApiKey, useAccount, useTheme, setOnboarding, showLogin, start, cancel, t, track }:
|
||||
export function AccountOnboarding({ complete, useApiKey, useAccount, useTheme, setOnboarding, showLogin, start, cancel, t }:
|
||||
PropsRuntime<'settings.models.sign-in'> & PropsLocale<'settings.account'> & InjectFace<AccountSectionInjected>) {
|
||||
const account = useAccount(value => value)
|
||||
const colorScheme = useTheme(snapshot => snapshot.active.colorScheme)
|
||||
useEffect(() => { setOnboarding(true); return () => { setOnboarding(false) } }, [setOnboarding])
|
||||
useEffect(() => { if (account.view?.status === 'credential-stored') complete() }, [account.view?.status, complete])
|
||||
if (!account.view || account.view.status === 'credential-stored') return null
|
||||
return <SignInDialog track={track} account={account} colorScheme={colorScheme} start={start} cancel={cancel} t={t}
|
||||
return <SignInDialog account={account} colorScheme={colorScheme} start={start} cancel={cancel} t={t}
|
||||
close={() => { showLogin(false); complete() }} useApiKey={() => { showLogin(false); useApiKey() }} />
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
/** Account settings renders safe Host state and explicit login actions. */
|
||||
import type { TrackProductEvent } from '@deepseek-ai/dsh-client-product-analytics/client'
|
||||
import { Big } from 'big.js'
|
||||
import { useEffect, useRef, useState, type MouseEvent } from 'react'
|
||||
import { Button, IconRightUpOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
@@ -34,8 +33,6 @@ export interface AccountSnapshot {
|
||||
|
||||
/** Host operations injected into the Cordis-free account component. */
|
||||
export interface AccountSectionInjected {
|
||||
/** Desktop-only event collection supplied by the application. */
|
||||
track?: TrackProductEvent
|
||||
/** Subscribe to live credential-expiry notifications.
|
||||
* @param listener - callback after the current credential is removed.
|
||||
* @returns listener cleanup.
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
/** Account authorization dialog; errors allow retry after cancelling any active attempt. */
|
||||
import type { TrackProductEvent } from '@deepseek-ai/dsh-client-product-analytics/client'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Button, IconCloseOutlineRegular, IconLoadingOutlineRegular, Modal } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { AccountSnapshot } from './AccountSection.tsx'
|
||||
import type { SignInAttemptId } from '@deepseek-ai/dsh-deepseek-account/types'
|
||||
@@ -9,8 +8,7 @@ import { authorizeUrlWithTheme } from './authorize-url.ts'
|
||||
import css from './SignInDialog.module.css'
|
||||
|
||||
/** @param props - safe account state, localized copy, and user actions. @returns login dialog. */
|
||||
export function SignInDialog({ account, colorScheme, start, cancel, close, useApiKey, t, track }: {
|
||||
track?: TrackProductEvent | undefined
|
||||
export function SignInDialog({ account, colorScheme, start, cancel, close, useApiKey, t }: {
|
||||
account: AccountSnapshot
|
||||
/** Resolved scheme of the active Desktop theme; the copied link carries it. */
|
||||
colorScheme: 'light' | 'dark'
|
||||
@@ -20,8 +18,6 @@ export function SignInDialog({ account, colorScheme, start, cancel, close, useAp
|
||||
useApiKey: () => void
|
||||
t: (key: AccountKey) => string
|
||||
}) {
|
||||
const viewed = useRef(false)
|
||||
useEffect(() => { if (!viewed.current) { viewed.current = true; track?.('auth_page_view', {}) } }, [track])
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [failed, setFailed] = useState(false)
|
||||
const [copyResult, setCopyResult] = useState<{ messageKey: 'copiedLink' | 'copyFailed' } | null>(null)
|
||||
@@ -80,9 +76,9 @@ export function SignInDialog({ account, colorScheme, start, cancel, close, useAp
|
||||
</div>
|
||||
<div className={css.actions}>
|
||||
<Button variant="outline" className={css.secondaryButton} disabled={committing || busy}
|
||||
onClick={active ? dismiss : () => { track?.('auth_page_click', { button_name: 'api-key' }); useApiKey() }}>{t(active ? 'cancel' : 'addApiKey')}</Button>
|
||||
onClick={active ? dismiss : useApiKey}>{t(active ? 'cancel' : 'addApiKey')}</Button>
|
||||
<Button variant="primary" className={css.primaryButton} disabled={busy || committing || waiting || account.view === undefined}
|
||||
aria-label={waiting ? t('waiting') : undefined} onClick={() => { track?.('auth_page_click', { button_name: 'sign_in' }); void run(retry) }}>
|
||||
aria-label={waiting ? t('waiting') : undefined} onClick={() => { void run(retry) }}>
|
||||
{waiting ? <IconLoadingOutlineRegular className={css.spinner} /> : t(expired || error ? 'retry' : 'signIn')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -156,7 +156,6 @@ export function apply(ctx: Context): void {
|
||||
if (reason === 'returned' && page === 'top-up') void refreshAfterReturn(refreshing, reRead)
|
||||
})
|
||||
const operations: AccountSectionInjected = {
|
||||
track: (name, attributes) => ctx.get('productAnalytics')?.track(name, attributes),
|
||||
subscribeSessionExpired: listener => ctx.remote.$on('deepseek-account/session-expired', listener),
|
||||
subscribeModelSignInRequired: listener => ctx.remote.$on('deepseek-account/model-sign-in-required', listener),
|
||||
...nativePlatform === undefined ? {} : { openPlatformPage: platformPageOpener(refreshAccount) },
|
||||
|
||||
@@ -273,8 +273,7 @@ it('re-reads profile and balance but no bonus for the onboarding recharge return
|
||||
const actions = operations(c)
|
||||
const track = vi.fn()
|
||||
c.ctx.provide('productAnalytics', { track } as never)
|
||||
actions.track?.('auth_page_view', {})
|
||||
expect(track).toHaveBeenCalledWith('auth_page_view', {})
|
||||
expect(actions).not.toHaveProperty('track')
|
||||
const onboarding = injectedOf(c.ctx.slots.entries('shell.overlay')
|
||||
.find(entry => entry.options.id === 'desktop-onboarding')!) as DesktopOnboardingInjected
|
||||
onboarding.track?.('onboarding_page_view', { page_name: 'onboarding_recharge' })
|
||||
|
||||
@@ -179,11 +179,15 @@ it('keeps an outstanding start open until the request settles', async () => {
|
||||
expect(screen.getByRole('button', { name: en.signIn }).hasAttribute('disabled')).toBe(false)
|
||||
})
|
||||
|
||||
|
||||
it('reports entry once across rerenders with a supplied sender', () => {
|
||||
// Workspace login remains outside native welcome analytics even if a caller forwards a sender.
|
||||
it('does not report workspace login views or login choices', async () => {
|
||||
const track = vi.fn()
|
||||
const props = dialogProps(null)
|
||||
const view = render(<SignInDialog {...props} track={track} />)
|
||||
view.rerender(<SignInDialog {...props} track={(name, attributes) => { track(name, attributes) }} colorScheme="light" />)
|
||||
expect(track).toHaveBeenCalledExactlyOnceWith('auth_page_view', {})
|
||||
const props = { ...dialogProps(null), track }
|
||||
const view = render(<SignInDialog {...props} />)
|
||||
view.rerender(<SignInDialog {...props} colorScheme="light" />)
|
||||
await act(async () => { fireEvent.click(screen.getByRole('button', { name: en.signIn })) })
|
||||
expect(props.start).toHaveBeenCalledOnce()
|
||||
await act(async () => { fireEvent.click(screen.getByRole('button', { name: en.addApiKey })) })
|
||||
expect(props.useApiKey).toHaveBeenCalledOnce()
|
||||
expect(track).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -15,8 +15,8 @@
|
||||
en: d152484eb41ac6b4
|
||||
zh: 9aa34fb1dc95d8cf
|
||||
/deepseek-ai-dsh-host-product-telemetry-otel/use-this-package:
|
||||
en: a62d2f83bd8b4186
|
||||
zh: f439a20ac0c4858e
|
||||
en: d2356d7abffcc0ce
|
||||
zh: 13250317296816ce
|
||||
/deepseek-ai-dsh-host-product-telemetry-otel/understand-the-implementation:
|
||||
en: d7807324fdeb2b42
|
||||
zh: 1bfe1e943e513500
|
||||
|
||||
@@ -25,7 +25,7 @@ Send selected product usage events to an OTLP/HTTP collector. Events carry a nam
|
||||
<a id="use-this-package"></a>
|
||||
## Use this package
|
||||
|
||||
Mount the plugin in a Cordis composition with the application identity; override the collector endpoint when needed. The Desktop composition mounts it when [product analytics](../../client/product-analytics/README.md) is enabled; ordinary Web does not. The standalone example below reads `DSH_APP_VERSION`; Desktop supplies `DSH_PRODUCT_ANALYTICS_APP_VERSION` through its native launcher. Both must name the running release; the schema rejects an absent version.
|
||||
Mount the plugin in a Cordis composition with the application identity; override the collector endpoint when needed. The Desktop composition mounts it when [product analytics](../../client/product-analytics/README.md) is enabled; ordinary Web does not. The standalone example below reads `DSH_APP_VERSION`; Desktop supplies `DSH_CLIENT_VERSION` through its native launcher. Both must name the running release; the schema rejects an absent version.
|
||||
|
||||
```yaml
|
||||
- name: '@deepseek-ai/dsh-otel'
|
||||
|
||||
@@ -25,7 +25,7 @@ kind: "package-reference"
|
||||
<a id="use-this-package"></a>
|
||||
## 使用本包
|
||||
|
||||
在 Cordis 组合中挂载插件并提供应用标识;需要时可覆盖接收地址。桌面组合在开启[产品埋点](../../client/product-analytics/README.zh.md)时挂载本插件;普通 Web 不挂载。下方独立示例读取 `DSH_APP_VERSION`;桌面端由原生启动器提供 `DSH_PRODUCT_ANALYTICS_APP_VERSION`。两者均须为运行中的发布版本;缺少版本时 schema 会拒绝配置。
|
||||
在 Cordis 组合中挂载插件并提供应用标识;需要时可覆盖接收地址。桌面组合在开启[产品埋点](../../client/product-analytics/README.zh.md)时挂载本插件;普通 Web 不挂载。下方独立示例读取 `DSH_APP_VERSION`;桌面端由原生启动器提供 `DSH_CLIENT_VERSION`。两者均须为运行中的发布版本;缺少版本时 schema 会拒绝配置。
|
||||
|
||||
```yaml
|
||||
- name: '@deepseek-ai/dsh-otel'
|
||||
|
||||
Reference in new issue
Block a user