From 4dee41f4bc71537d9e6c73c9f98cb4fa10253338 Mon Sep 17 00:00:00 2001 From: 07akioni <07akioni2@gmail.com> Date: Wed, 14 Oct 2026 15:23:58 +0800 Subject: [PATCH 1/2] feat(telemetry): attach cached desktop environment to product events --- docs/config-catalog.md | 6 +- docs/config-catalog.zh.md | 6 +- docs/subsystems/product-telemetry.md | 4 +- docs/subsystems/product-telemetry.zh.md | 4 +- .../web-app/tests/product-analytics.spec.ts | 6 +- .../client/product-analytics/README.i18n.yaml | 8 +- packages/client/product-analytics/README.md | 24 ++- .../client/product-analytics/README.zh.md | 24 ++- .../client/product-analytics/package.json | 4 +- .../src/environment-query.ts | 42 ++++ .../product-analytics/src/environment.ts | 172 +++++++++++++++++ .../client/product-analytics/src/index.ts | 35 +++- .../tests/collection.host.spec.ts | 74 +++++++- .../tests/environment-composition.host.e2e.ts | 43 +++++ .../tests/environment-query.host.spec.ts | 42 ++++ .../tests/environment.host.spec.ts | 179 ++++++++++++++++++ .../tests/fixtures/environment.host.ts | 66 +++++++ .../tests/fixtures/environment.patch.yml | 19 ++ .../product-analytics/tsconfig.host.json | 4 +- .../extensions/tool-cordis/src/api-catalog.ts | 2 +- pnpm-lock.yaml | 6 + tsconfig.client.json | 4 +- 22 files changed, 743 insertions(+), 31 deletions(-) create mode 100644 packages/client/product-analytics/src/environment-query.ts create mode 100644 packages/client/product-analytics/src/environment.ts create mode 100644 packages/client/product-analytics/tests/environment-composition.host.e2e.ts create mode 100644 packages/client/product-analytics/tests/environment-query.host.spec.ts create mode 100644 packages/client/product-analytics/tests/environment.host.spec.ts create mode 100644 packages/client/product-analytics/tests/fixtures/environment.host.ts create mode 100644 packages/client/product-analytics/tests/fixtures/environment.patch.yml diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 8bfae862ef..c1d5e8e0f5 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -498,7 +498,7 @@ export interface Config { - `inject`: `deepseekAccount` · `productTelemetry` - `refs`: `Volatile` (`@deepseek-ai/cordis`) -- `source`: [`packages/client/product-analytics/src/index.ts:14`](../packages/client/product-analytics/src/index.ts) +- `source`: [`packages/client/product-analytics/src/index.ts:15`](../packages/client/product-analytics/src/index.ts) ```ts config-catalog /** Application-owned collection policy; no user settings surface. */ @@ -507,6 +507,10 @@ export interface Config { enabled: Volatile /** Running Desktop release, absent when unavailable. */ appVersion?: string + /** Maximum execution time per static environment query, in milliseconds. */ + environmentQueryTimeoutMillis: number + /** Maximum captured bytes per environment query output stream. */ + environmentQueryMaxOutputBytes: number } ``` diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 68dfbf1852..068fed6c49 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -500,7 +500,7 @@ export interface Config { - `inject`: `deepseekAccount` · `productTelemetry` - `refs`: `Volatile` (`@deepseek-ai/cordis`) -- `source`: [`packages/client/product-analytics/src/index.ts:14`](../packages/client/product-analytics/src/index.ts) +- `source`: [`packages/client/product-analytics/src/index.ts:15`](../packages/client/product-analytics/src/index.ts) ```ts config-catalog /** Application-owned collection policy; no user settings surface. */ @@ -509,6 +509,10 @@ export interface Config { enabled: Volatile /** Running Desktop release, absent when unavailable. */ appVersion?: string + /** Maximum execution time per static environment query, in milliseconds. */ + environmentQueryTimeoutMillis: number + /** Maximum captured bytes per environment query output stream. */ + environmentQueryMaxOutputBytes: number } ``` diff --git a/docs/subsystems/product-telemetry.md b/docs/subsystems/product-telemetry.md index 00c70a45c8..9d656631f6 100644 --- a/docs/subsystems/product-telemetry.md +++ b/docs/subsystems/product-telemetry.md @@ -51,7 +51,9 @@ Authenticated event intake; disabled instances do not inspect identity or accept @Remote({ mode: 'stream' }) async *watchPolicy(signal: AbortSignal): AsyncIterable /** - * Submit selected Desktop fields; missing identity is omitted and never generated. + * Submit selected Desktop fields with cached static environment and fresh identity. + * The first intake waits for bounded environment queries; concurrent intakes share them. + * Missing identity is omitted; failed environment fields carry fixed reasons. * @param event - typed product event without message contents or credentials. * @returns after local submission; no delivery or warehouse acknowledgement. */ diff --git a/docs/subsystems/product-telemetry.zh.md b/docs/subsystems/product-telemetry.zh.md index 5b04bf6fdd..e8072807ec 100644 --- a/docs/subsystems/product-telemetry.zh.md +++ b/docs/subsystems/product-telemetry.zh.md @@ -51,7 +51,9 @@ Authenticated event intake; disabled instances do not inspect identity or accept @Remote({ mode: 'stream' }) async *watchPolicy(signal: AbortSignal): AsyncIterable /** - * Submit selected Desktop fields; missing identity is omitted and never generated. + * Submit selected Desktop fields with cached static environment and fresh identity. + * The first intake waits for bounded environment queries; concurrent intakes share them. + * Missing identity is omitted; failed environment fields carry fixed reasons. * @param event - typed product event without message contents or credentials. * @returns after local submission; no delivery or warehouse acknowledgement. */ diff --git a/packages/bundle/web-app/tests/product-analytics.spec.ts b/packages/bundle/web-app/tests/product-analytics.spec.ts index f90f7fade4..d9bf9f31dd 100644 --- a/packages/bundle/web-app/tests/product-analytics.spec.ts +++ b/packages/bundle/web-app/tests/product-analytics.spec.ts @@ -72,7 +72,11 @@ it.each(['desktop', 'web'])('limits collection and its shutdown to the Desktop l } 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' } })) + expect(emit).toHaveBeenCalledOnce() + const attributes = emit.mock.calls[0]?.[0].attributes + expect(attributes?.app_version).toBe('test-version') + expect(typeof attributes?.os_name).toBe('string') + expect(typeof attributes?.memory_total_bytes).toBe('number') lifetime.abort() await policy.return?.() ctx.productTelemetry.emit({ eventName: 'desktop_upgrade_install_restart_click', body: 'upgrade', timestamp: Date.now() }) diff --git a/packages/client/product-analytics/README.i18n.yaml b/packages/client/product-analytics/README.i18n.yaml index 9b47a9f66e..989c03c5b2 100644 --- a/packages/client/product-analytics/README.i18n.yaml +++ b/packages/client/product-analytics/README.i18n.yaml @@ -15,11 +15,11 @@ en: 2fee9973f19f9401 zh: 8ab4b55413cb8408 /deepseek-ai-dsh-client-product-analytics/use-this-package: - en: 3932aceab765ab96 - zh: 8b5174fff1208053 + en: 062e1847cfcc60ee + zh: ea1b6cc2d2413226 /deepseek-ai-dsh-client-product-analytics/understand-the-implementation: - en: aa37aaf70a94e620 - zh: 23b9a01ed88cb772 + en: 96b1169d78f4c761 + zh: 475df4ea6ed1cb06 /deepseek-ai-dsh-client-product-analytics/model-experience: en: 291751b16b8a37e7 zh: 3b38008f4364ec41 diff --git a/packages/client/product-analytics/README.md b/packages/client/product-analytics/README.md index 941025b2de..1317bcd5fe 100644 --- a/packages/client/product-analytics/README.md +++ b/packages/client/product-analytics/README.md @@ -21,11 +21,27 @@ Desktop reports selected interactions through the existing OTel product exporter ## Use this package -Desktop mounts Analytics and its required Telemetry exporter. The `product-analytics` settings namespace owns the live `enabled` field, which defaults to `true`; configure it through the existing Cordis Config / settings mechanism. There is no user-facing control. Ordinary Web mounts neither service. Disabled collection reads no analytics identity and accepts no new events; the exporter remains mounted and may finish exporting records already queued. Session-feedback telemetry has its own policy. +Desktop mounts Analytics and its required Telemetry exporter. The `product-analytics` settings namespace owns the live `enabled` field, which defaults to `true`; configure it through the existing Cordis Config / settings mechanism. There is no user-facing control. Ordinary Web mounts neither service. Disabled collection starts no environment queries, reads no analytics identity and accepts no new events; the exporter remains mounted and may finish exporting records already queued. Session-feedback telemetry has its own policy. -The renderer and Electron subscribe to the Host policy through the existing authenticated stream, including reconnection. Electron also reads the initial policy before native launch reporting. Welcome obtains the current policy over IPC. The Host checks its current volatile configuration again at event intake and after identity lookup. `DSH_PRODUCT_ANALYTICS_OTLP_URL` overrides the export destination for isolated collectors. The [exporter](../../host/product-telemetry-otel/README.md) owns batching, retry, and shutdown delivery. +The renderer and Electron subscribe to the Host policy through the existing authenticated stream, including reconnection. Electron also reads the initial policy before native launch reporting. Welcome obtains the current policy over IPC. The Host checks its current volatile configuration again at event intake and after environment and identity lookup. `DSH_PRODUCT_ANALYTICS_OTLP_URL` overrides the export destination for isolated collectors. The [exporter](../../host/product-telemetry-otel/README.md) owns batching, retry, and shutdown delivery. -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. +Every product event carries available `device_id`, `user_id`, and `app_version` plus the static fields below. Identity reuses the existing login record without creating an identifier and is read for each event through `deepseekAccount.getDeviceIdentity()`. Static environment collection is independent of identity lookup. No separate environment event is emitted; API keys, account tokens, prompts, responses, hostnames, and machine UUIDs are excluded. + +| Field | Meaning and source | +|---|---| +| `os_name` | `windows` or `macos`; Node platform selects the mapping | +| `os_version` | Legacy ` ` from `node:os.release()` | +| `os_version_full` | Windows registry major/minor/build plus `UBR` revision; macOS `sw_vers` product version including a security-response suffix when present | +| `os_build` | Windows registry `CurrentBuildNumber`; macOS `sw_vers` build | +| `os_arch` | Native architecture mapped to `ia32`, `x64`, `arm`, or `arm64`; Windows processor CIM architecture or macOS `hw.optional.arm64`, independent of application emulation | +| `app_arch` | Running binary architecture from `process.arch` | +| `cpu_model` | First logical processor's model from `node:os.cpus()` | +| `cpu_logical_count` | Number of system-reported logical processors from `node:os.cpus()`, not application parallelism | +| `cpu_physical_core_count` | Sum of Windows processor CIM `NumberOfCores` across sockets; macOS `hw.physicalcpu` | +| `memory_total_bytes` | Total physical memory from `node:os.totalmem()`, in bytes without rounding | +| `missing_fields` | Object mapping omitted environment field names to `unavailable`, `invalid-value`, `query-failed`, `timeout`, `output-limit`, or `unsupported`; empty when all fields are available | + +The first enabled intake collects one environment snapshot; concurrent and later intakes reuse its successful fields and failures until the plugin remounts or the Host restarts. Intake waits for sampling while callers submit asynchronously; startup does not await collection. Identity changes remain visible on subsequent events. Each metadata command has a configurable `environmentQueryTimeoutMillis` deadline (5,000 ms) and `environmentQueryMaxOutputBytes` bound per output stream (16,384 bytes). Disposal cancels commands and waits for child closure; disabled or disposed intake never emits late results. Missing revision never becomes `.0`, and missing numeric fields never become zero. Only Windows and macOS have full-version and physical-core queries; other platforms mark those fields `unsupported`. Electron forwards its build-inlined `DSH_CLIENT_VERSION` to the Host; analytics and the exporter reuse this client version. The exporter requires it. The [Desktop composition](../../bundle/web-app/README.md) owns batching and timeout settings, including cancellation at the shutdown deadline. @@ -39,7 +55,7 @@ Only `plugin_toggle` carries `plugin_type`: plugin rows use `plugin`, and packag
Implementation internals — click to expand -Presentational components receive callbacks. Shared Client services resolve the optional Desktop analytics sender, which requires both the native preload marker and the synchronized Host policy. The sender retains its own RPC context when called by other plugins. Native actions use the authenticated Host API; its generated Typert validator accepts only the typed event fields. The Host enriches identity immediately before queueing. It observes live compaction events without replaying Session history. +Presentational components receive callbacks. Shared Client services resolve the optional Desktop analytics sender, which requires both the native preload marker and the synchronized Host policy. The sender retains its own RPC context when called by other plugins. Native actions use the authenticated Host API; its generated Typert validator accepts only the typed event fields. The Host attaches its cached static environment and current identity before queueing. It observes live compaction events without replaying Session history.
diff --git a/packages/client/product-analytics/README.zh.md b/packages/client/product-analytics/README.zh.md index be44c8e742..c5d588b851 100644 --- a/packages/client/product-analytics/README.zh.md +++ b/packages/client/product-analytics/README.zh.md @@ -21,11 +21,27 @@ kind: "package-reference" ## 使用本包 -桌面端同时装配 Analytics 与必需的 Telemetry 导出器。`product-analytics` settings 命名空间持有动态 `enabled` 字段,默认 `true`,通过现有 Cordis Config / settings 机制配置,暂不提供用户操作入口。普通 Web 不装配这两个服务。关闭后不读取埋点身份、不接收新事件;导出器仍保持挂载,已入队事件可以继续导出。会话反馈遥测采用独立策略。 +桌面端同时装配 Analytics 与必需的 Telemetry 导出器。`product-analytics` settings 命名空间持有动态 `enabled` 字段,默认 `true`,通过现有 Cordis Config / settings 机制配置,暂不提供用户操作入口。普通 Web 不装配这两个服务。关闭后不启动环境查询、不读取埋点身份、不接收新事件;导出器仍保持挂载,已入队事件可以继续导出。会话反馈遥测采用独立策略。 -渲染端和 Electron 通过现有认证流订阅 Host 策略变化及重连后的值;Electron 在原生启动上报前还会读取初始策略。欢迎窗口通过 IPC 获取当前策略。Host 在接收事件及读取身份后都检查当前 volatile 配置。`DSH_PRODUCT_ANALYTICS_OTLP_URL` 可覆盖导出目的地,用于隔离的接收端。[导出器](../../host/product-telemetry-otel/README.zh.md)负责批量发送、重试和退出时的交付。 +渲染端和 Electron 通过现有认证流订阅 Host 策略变化及重连后的值;Electron 在原生启动上报前还会读取初始策略。欢迎窗口通过 IPC 获取当前策略。Host 在接收事件及读取环境和身份后都检查当前 volatile 配置。`DSH_PRODUCT_ANALYTICS_OTLP_URL` 可覆盖导出目的地,用于隔离的接收端。[导出器](../../host/product-telemetry-otel/README.zh.md)负责批量发送、重试和退出时的交付。 -公共字段为 `device_id`、`user_id`、`os_version` 和 `app_version`。设备身份复用现有登录记录,不会生成新标识。Host 通过 `deepseekAccount.getDeviceIdentity()` 读取不含凭据的设备、账户和操作系统字段。缺失值会省略;API key、账户令牌、提示词和模型回复都不是事件字段。 +每条产品事件携带可用的 `device_id`、`user_id` 和 `app_version`,以及下表中的静态字段。身份复用现有登录记录,不创建设备标识;每条事件通过 `deepseekAccount.getDeviceIdentity()` 读取身份。静态环境采集独立于身份读取。不发送单独的环境事件;排除 API key、账户令牌、提示词、模型回复、主机名和机器 UUID。 + +| 字段 | 含义与来源 | +|---|---| +| `os_name` | `windows` 或 `macos`;由 Node 平台选择映射 | +| `os_version` | 原有的 ` <内核版本>`,来自 `node:os.release()` | +| `os_version_full` | Windows 注册表 major/minor/build 加上 `UBR` 修订号;macOS `sw_vers` 产品版本,包括存在时的安全响应后缀 | +| `os_build` | Windows 注册表 `CurrentBuildNumber`;macOS `sw_vers` build | +| `os_arch` | 原生架构映射为 `ia32`、`x64`、`arm` 或 `arm64`;来自 Windows 处理器 CIM 架构或 macOS `hw.optional.arm64`,独立于应用仿真运行 | +| `app_arch` | 来自 `process.arch` 的运行二进制架构 | +| `cpu_model` | 来自 `node:os.cpus()` 的首个逻辑处理器型号 | +| `cpu_logical_count` | 来自 `node:os.cpus()` 的系统报告逻辑处理器数量,不表示应用可用并行度 | +| `cpu_physical_core_count` | Windows 各 socket 处理器 CIM `NumberOfCores` 之和;macOS `hw.physicalcpu` | +| `memory_total_bytes` | 来自 `node:os.totalmem()` 的总物理内存,单位 bytes,不取整 | +| `missing_fields` | 对象,将省略的环境字段名映射到 `unavailable`、`invalid-value`、`query-failed`、`timeout`、`output-limit` 或 `unsupported`;全部字段可用时为空 | + +首次启用状态下的事件接收采集一份环境快照;并发和后续事件复用成功字段及失败状态,直到插件重新挂载或 Host 重启。事件接收等待采样,调用方异步提交;启动不等待采集。后续事件仍能反映身份变化。每条元数据命令具有可配置的 `environmentQueryTimeoutMillis` 期限(5,000 毫秒)和每路输出的 `environmentQueryMaxOutputBytes` 上限(16,384 bytes)。卸载取消命令并等待子进程关闭;禁用或卸载后的事件接收不会发送迟到结果。缺失修订号不会变为 `.0`,缺失数字字段不会变为零。只有 Windows 和 macOS 查询完整版本与物理核心数;其他平台将这些字段标记为 `unsupported`。 Electron 将构建内联的 `DSH_CLIENT_VERSION` 传给 Host;埋点与导出器复用这一客户端版本,导出器要求该值存在。[桌面组合](../../bundle/web-app/README.zh.md)负责批量发送与超时配置,包括达到关闭期限时的取消行为。 @@ -39,7 +55,7 @@ Electron 将构建内联的 `DSH_CLIENT_VERSION` 传给 Host;埋点与导出
实现细节——点击展开 -展示组件接收回调。共享 Client 服务读取可选的桌面埋点发送器,后者要求同时存在原生 preload 标识和 同步的 Host 采集策略。发送器在被其他插件调用时保留自身的 RPC 上下文。原生操作使用已认证的 Host API,其生成的 Typert 校验器只接受类型中声明的事件字段。Host 在入队前补充身份信息,并监听实时压缩事件,不重放会话历史。 +展示组件接收回调。共享 Client 服务读取可选的桌面埋点发送器,后者要求同时存在原生 preload 标识和 同步的 Host 采集策略。发送器在被其他插件调用时保留自身的 RPC 上下文。原生操作使用已认证的 Host API,其生成的 Typert 校验器只接受类型中声明的事件字段。Host 在入队前附加缓存的静态环境和当前身份信息,并监听实时压缩事件,不重放会话历史。
diff --git a/packages/client/product-analytics/package.json b/packages/client/product-analytics/package.json index a8240ccc6b..02c111a9b7 100644 --- a/packages/client/product-analytics/package.json +++ b/packages/client/product-analytics/package.json @@ -64,7 +64,9 @@ "@deepseek-ai/dsh-host-webserver": "workspace:*", "@deepseek-ai/dsh-settings": "workspace:*", "@deepseek-ai/cordis-plugin-loader": "workspace:~", - "@deepseek-ai/dsh-otel": "workspace:*" + "@deepseek-ai/dsh-otel": "workspace:*", + "@deepseek-ai/dsh-app-boot": "workspace:*", + "@deepseek-ai/dsh-loader-smoke": "workspace:*" }, "files": [ "locale/*.json", diff --git a/packages/client/product-analytics/src/environment-query.ts b/packages/client/product-analytics/src/environment-query.ts new file mode 100644 index 0000000000..8086fff82f --- /dev/null +++ b/packages/client/product-analytics/src/environment-query.ts @@ -0,0 +1,42 @@ +/** Bounded, cancellable queries of fixed operating-system metadata commands. */ +import { execFile } from 'node:child_process' + +/** Fixed reasons exposed instead of command output or raw errors. */ +export type MissingReason = 'unavailable' | 'invalid-value' | 'query-failed' | 'timeout' | 'output-limit' | 'unsupported' + +/** Successful stdout or an isolated query failure. */ +export type QueryResult = { kind: 'value'; stdout: string } | { kind: 'missing'; reason: MissingReason } + +/** Deployment limits and the owning analytics lifetime. */ +export interface QueryOptions { + /** Maximum execution time of each query, in milliseconds. */ + timeoutMillis: number + /** Maximum bytes captured from either output stream. */ + maxOutputBytes: number + /** Disposal aborts the command and waits for its close event. */ + signal: AbortSignal +} + +/** + * Execute fixed argv without a shell; failures omit only dependent metadata. + * @param command - operating-system executable, never event-supplied text. + * @param args - fixed metadata query arguments. + * @param options - output/time limits and owner lifetime. + * @returns stdout or a fixed failure reason, after the child closes. + */ +export async function queryEnvironment(command: string, args: readonly string[], options: QueryOptions): Promise { + const result = Promise.withResolvers() + const child = execFile(command, [...args], { + encoding: 'utf8', windowsHide: true, signal: options.signal, killSignal: 'SIGKILL', + timeout: options.timeoutMillis, maxBuffer: options.maxOutputBytes, + }, (error, stdout) => { + if (error === null) { result.resolve({ kind: 'value', stdout }); return } + const reason = error.code === 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER' ? 'output-limit' + : error.killed && !options.signal.aborted ? 'timeout' : 'query-failed' + result.resolve({ kind: 'missing', reason }) + }) + // execFile can report abort before stdio closes; disposal owns both settlements. + const closed = new Promise((resolve) => { child.once('close', () => { resolve() }) }) + const [output] = await Promise.all([result.promise, closed]) + return output +} diff --git a/packages/client/product-analytics/src/environment.ts b/packages/client/product-analytics/src/environment.ts new file mode 100644 index 0000000000..7f067cc7b8 --- /dev/null +++ b/packages/client/product-analytics/src/environment.ts @@ -0,0 +1,172 @@ +/** Static Desktop environment fields, independent of account and credential availability. */ +import { cpus, machine, platform, release, totalmem } from 'node:os' +import { join } from 'node:path' +import { z } from 'zod' +import { queryEnvironment, type MissingReason, type QueryOptions, type QueryResult } from './environment-query.ts' + +/** Approved scalar attributes; missing fields carry fixed reasons. */ +export interface EnvironmentAttributes { + /** Platform name: windows, macos, or the unsupported Node platform. */ + os_name: string + /** Legacy platform and kernel release string. */ + os_version?: string + /** Windows major.minor.build.revision or macOS product version. */ + os_version_full?: string + /** Windows build or macOS product build. */ + os_build?: string + /** Native system architecture, independent of process emulation. */ + os_arch?: string + /** Architecture of the running application binary. */ + app_arch: string + /** Model reported by the first logical processor. */ + cpu_model?: string + /** System-reported logical processor count, not available parallelism. */ + cpu_logical_count?: number + /** Total physical cores across CPU sockets; never inferred from logical processors. */ + cpu_physical_core_count?: number + /** Total physical memory in bytes. */ + memory_total_bytes?: number + /** Only failed fields, with no raw commands, output, or errors. */ + missing_fields: Record +} + +type EnvironmentField = Exclude + +const windowsVersion = z.object({ + major: z.number().int().nonnegative().nullable(), minor: z.number().int().nonnegative().nullable(), + build: z.string().regex(/^\d+$/).nullable(), revision: z.number().int().nonnegative().nullable(), +}) +const windowsCpu = z.array(z.object({ cores: z.number().int().positive().nullable(), architecture: z.number().int().nullable() })).min(1) + +const VERSION_QUERY = ` +$ErrorActionPreference = 'Stop' +$r = Get-Item -LiteralPath 'HKLM:\\SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion' +@{ major = $r.GetValue('CurrentMajorVersionNumber'); minor = $r.GetValue('CurrentMinorVersionNumber'); build = $r.GetValue('CurrentBuildNumber'); revision = $r.GetValue('UBR') } | ConvertTo-Json -Compress +` +const CPU_QUERY = ` +$ErrorActionPreference = 'Stop' +ConvertTo-Json -Compress -InputObject @(Get-CimInstance -ClassName Win32_Processor -Property NumberOfCores,Architecture | ForEach-Object { @{ cores = $_.NumberOfCores; architecture = $_.Architecture } }) +` + +/** + * Collect only approved metadata; a failed source leaves its dependent fields missing. + * @param options - per-command bounds and analytics disposal signal. + * @returns a snapshot for reuse throughout this analytics instance, including failed fields. + */ +export async function collectEnvironment(options: QueryOptions): Promise { + const hostPlatform = platform() + const fields: EnvironmentAttributes = { + os_name: hostPlatform === 'darwin' ? 'macos' : hostPlatform === 'win32' ? 'windows' : hostPlatform, + app_arch: process.arch, missing_fields: {}, + } + const missing = (keys: readonly EnvironmentField[], reason: MissingReason): void => { + for (const key of keys) fields.missing_fields[key] = reason + } + const read = (key: K, source: () => EnvironmentAttributes[K]): void => { + let value: EnvironmentAttributes[K] + try { value = source() } catch (_error) { + // The failed OS source cannot invalidate independently collected fields. + missing([key], 'unavailable'); return + } + if (value === undefined || value === '' || (typeof value === 'number' && (!Number.isSafeInteger(value) || value <= 0))) { + missing([key], 'unavailable'); return + } + Object.assign(fields, { [key]: value }) + } + read('os_version', () => `${hostPlatform} ${release()}`) + read('memory_total_bytes', totalmem) + let processors: ReturnType = [] + try { processors = cpus() } catch (_error) { + // CPU metadata is optional; memory and OS collection remain usable. + } + read('cpu_model', () => processors[0]?.model.trim()) + read('cpu_logical_count', () => processors.length) + + if (hostPlatform === 'win32') { + const root = process.env.SystemRoot + if (root === undefined || root === '') { + missing(['os_version_full', 'os_build', 'os_arch', 'cpu_physical_core_count'], 'unavailable') + return fields + } + const command = join(root, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe') + const query = (script: string): Promise => queryEnvironment(command, + ['-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from(script, 'utf16le').toString('base64')], options) + const [version, cpu] = await Promise.all([query(VERSION_QUERY), query(CPU_QUERY)]) + if (version.kind === 'missing') missing(['os_version_full', 'os_build'], version.reason) + else { + const parsed = decode(version.stdout, windowsVersion) + if (parsed === undefined) missing(['os_version_full', 'os_build'], 'invalid-value') + else { + if (parsed.build === null) missing(['os_build'], 'unavailable') + else fields.os_build = parsed.build + if (parsed.major === null || parsed.minor === null || parsed.build === null || parsed.revision === null) missing(['os_version_full'], 'unavailable') + else fields.os_version_full = `${parsed.major}.${parsed.minor}.${parsed.build}.${parsed.revision}` + } + } + if (cpu.kind === 'missing') missing(['os_arch', 'cpu_physical_core_count'], cpu.reason) + else { + const parsed = decode(cpu.stdout, windowsCpu) + if (parsed === undefined) missing(['os_arch', 'cpu_physical_core_count'], 'invalid-value') + else { + const counts = parsed.map(value => value.cores) + if (counts.every((count): count is number => count !== null)) read('cpu_physical_core_count', () => counts.reduce((sum, count) => sum + count, 0)) + else missing(['cpu_physical_core_count'], 'unavailable') + const architectures: Record = { 0: 'ia32', 5: 'arm', 9: 'x64', 12: 'arm64' } + const arch = parsed[0]?.architecture + if (arch == null || !parsed.every(value => value.architecture === arch) || architectures[arch] === undefined) missing(['os_arch'], 'unavailable') + else fields.os_arch = architectures[arch] + } + } + } else if (hostPlatform === 'darwin') { + const [version, cpu] = await Promise.all([ + queryEnvironment('/usr/bin/sw_vers', [], options), + queryEnvironment('/usr/sbin/sysctl', ['-i', 'hw.physicalcpu', 'hw.optional.arm64'], options), + ]) + if (version.kind === 'missing') missing(['os_version_full', 'os_build'], version.reason) + else { + const values = lines(version.stdout) + const product = values.get('ProductVersion') + const extra = values.get('ProductVersionExtra') + if (product !== undefined && /^\d+(?:\.\d+){1,2}$/.test(product) && (extra === undefined || /^\([a-z]\)$/.test(extra))) { + fields.os_version_full = extra === undefined ? product : `${product} ${extra}` + } else missing(['os_version_full'], 'invalid-value') + const build = values.get('BuildVersion') + if (build !== undefined && /^\d+[A-Za-z]\d+[a-z]?$/.test(build)) fields.os_build = build + else missing(['os_build'], 'invalid-value') + } + if (cpu.kind === 'missing') missing(['os_arch', 'cpu_physical_core_count'], cpu.reason) + else { + const values = lines(cpu.stdout) + const cores = values.get('hw.physicalcpu') + if (cores !== undefined && /^\d+$/.test(cores) && Number.isSafeInteger(Number(cores)) && Number(cores) > 0) { + fields.cpu_physical_core_count = Number(cores) + } + else missing(['cpu_physical_core_count'], 'invalid-value') + const arm = values.get('hw.optional.arm64') + if (arm === '1') fields.os_arch = 'arm64' + else if (arm === '0') fields.os_arch = 'x64' + else missing(['os_arch'], 'unavailable') + } + } else { + read('os_arch', () => machine()) + missing(['os_version_full', 'os_build', 'cpu_physical_core_count'], 'unsupported') + } + return fields +} + +function lines(stdout: string): Map { + return new Map(stdout.trim().split(/\r?\n/).map((line) => { + const colon = line.indexOf(':') + return [line.slice(0, colon).trim(), line.slice(colon + 1).trim()] + })) +} + +function decode(stdout: string, schema: z.ZodType): T | undefined { + let value: unknown + try { value = JSON.parse(stdout) } catch (_error) { + // Malformed command JSON supplies no validated metadata. + return undefined + } + const result = schema.safeParse(value) + return result.success ? result.data : undefined +} diff --git a/packages/client/product-analytics/src/index.ts b/packages/client/product-analytics/src/index.ts index 3eae234d57..ce497f99df 100644 --- a/packages/client/product-analytics/src/index.ts +++ b/packages/client/product-analytics/src/index.ts @@ -9,6 +9,7 @@ import type {} from '@deepseek-ai/cordis-plugin-loader' import type {} from '@deepseek-ai/dsh-session' import type {} from '@deepseek-ai/dsh-compaction/types' import type { ProductEvent } from './events.ts' +import { collectEnvironment, type EnvironmentAttributes } from './environment.ts' /** Application-owned collection policy; no user settings surface. */ export interface Config { @@ -16,6 +17,10 @@ export interface Config { enabled: Volatile /** Running Desktop release, absent when unavailable. */ appVersion?: string + /** Maximum execution time per static environment query, in milliseconds. */ + environmentQueryTimeoutMillis: number + /** Maximum captured bytes per environment query output stream. */ + environmentQueryMaxOutputBytes: number } declare module '@deepseek-ai/cordis' { @@ -27,13 +32,24 @@ declare module '@deepseek-ai/cordis' { /** Authenticated event intake; disabled instances do not inspect identity or accept new events. */ export default class ProductAnalytics extends TypertRemoteService { static inject = ['deepseekAccount', 'productTelemetry'] - static Config = z.object({ enabled: z.boolean().default(true).volatile(), appVersion: z.string() }) + static Config = z.object({ + enabled: z.boolean().default(true).volatile(), appVersion: z.string(), + environmentQueryTimeoutMillis: z.number().step(1).min(1).max(2_147_483_647).default(5000), + environmentQueryMaxOutputBytes: z.number().step(1).min(1).max(2_147_483_647).default(16384), + }) private active = true + private readonly environmentLifetime = new AbortController() + private environment: Promise | undefined private readonly listeners = new Set<() => void>() constructor(ctx: Context, private readonly config: Config) { super(ctx, 'productAnalytics') - ctx.effect(() => () => { this.active = false; for (const listener of this.listeners) listener() }) + ctx.effect(() => async () => { + this.active = false + for (const listener of this.listeners) listener() + this.environmentLifetime.abort() + await this.environment + }) ctx.on('loader/volatile-update', () => { for (const listener of this.listeners) listener() }) ctx.inject(['settings'], (child) => { child.effect(() => child.settings.configure({ auto: false }, ctx.fiber)) }) ctx.on('session/event', (session, event) => { @@ -74,7 +90,9 @@ export default class ProductAnalytics extends TypertRemoteService { } /** - * Submit selected Desktop fields; missing identity is omitted and never generated. + * Submit selected Desktop fields with cached static environment and fresh identity. + * The first intake waits for bounded environment queries; concurrent intakes share them. + * Missing identity is omitted; failed environment fields carry fixed reasons. * @param event - typed product event without message contents or credentials. * @returns after local submission; no delivery or warehouse acknowledgement. */ @@ -82,16 +100,23 @@ export default class ProductAnalytics extends TypertRemoteService { async report(event: ProductEvent): Promise { if (!this.enabled()) return try { - const identity = await this.ctx.deepseekAccount.getDeviceIdentity().catch(() => undefined) + this.environment ??= collectEnvironment({ + timeoutMillis: this.config.environmentQueryTimeoutMillis, + maxOutputBytes: this.config.environmentQueryMaxOutputBytes, + signal: this.environmentLifetime.signal, + }) + const [environment, identity] = await Promise.all([ + this.environment, this.ctx.deepseekAccount.getDeviceIdentity().catch(() => undefined), + ]) if (!this.enabled()) return this.ctx.productTelemetry.emit({ ...event, body: event.eventName, attributes: { ...event.attributes, + ...environment, ...identity?.deviceId === undefined ? {} : { device_id: identity.deviceId }, ...identity?.userId === undefined ? {} : { user_id: identity.userId }, ...this.config.appVersion === undefined ? {} : { app_version: this.config.appVersion }, - ...identity === undefined ? {} : { os_version: identity.osVersion }, }, }) } catch (error) { diff --git a/packages/client/product-analytics/tests/collection.host.spec.ts b/packages/client/product-analytics/tests/collection.host.spec.ts index c7878a8940..5538f4c513 100644 --- a/packages/client/product-analytics/tests/collection.host.spec.ts +++ b/packages/client/product-analytics/tests/collection.host.spec.ts @@ -2,11 +2,20 @@ import OTel from '@deepseek-ai/dsh-otel' import { Context } from '@deepseek-ai/cordis' import { createServer } from 'node:http' import { once } from 'node:events' -import { afterEach, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' import ProductTelemetry, { Config as TelemetryConfig, type ProductTelemetryRecord } from '@deepseek-ai/dsh-host-product-telemetry-otel' import { Session, SessionId, SessionSeq } from '@deepseek-ai/dsh-session' import { CompactionId } from '@deepseek-ai/dsh-compaction' import Analytics from '../src/index.ts' +import { collectEnvironment, type EnvironmentAttributes } from '../src/environment.ts' + +vi.mock('../src/environment.ts', () => ({ collectEnvironment: vi.fn() })) +const environment: EnvironmentAttributes = { + os_name: 'windows', os_version: 'win32 10.0.26300', os_version_full: '10.0.26300.9457', os_build: '26300', + os_arch: 'arm64', app_arch: 'x64', cpu_model: 'Fixture CPU', cpu_logical_count: 16, cpu_physical_core_count: 8, + memory_total_bytes: 34359738368, missing_fields: {}, +} +beforeEach(() => { vi.resetAllMocks(); vi.mocked(collectEnvironment).mockResolvedValue(environment) }) const cleanup: (() => Promise)[] = [] afterEach(async () => { for (const dispose of cleanup.splice(0).reverse()) await dispose() }) @@ -27,6 +36,7 @@ it('disabled collection does not read identity or submit an event', async () => const b = await setup(false) expect(b.ctx.productAnalytics.enabled()).toBe(false) await b.ctx.productAnalytics.report({ eventName: 'desktop_app_launch', timestamp: 100, attributes: {} }) + expect(collectEnvironment).not.toHaveBeenCalled() expect(b.getDeviceIdentity).not.toHaveBeenCalled() expect(b.emit).not.toHaveBeenCalled() }) @@ -37,7 +47,7 @@ it('reuses login identity and copies only approved common fields', async () => { expect(b.getDeviceIdentity).toHaveBeenCalledWith() expect(b.emit).toHaveBeenCalledExactlyOnceWith({ eventName: 'auth_page_click', body: 'auth_page_click', timestamp: 100, - attributes: { button_name: 'sign_in', device_id: 'login-device', user_id: 'user-1', app_version: 'test-version', os_version: expect.any(String) as string }, + attributes: { ...environment, button_name: 'sign_in', device_id: 'login-device', user_id: 'user-1', app_version: 'test-version' }, }) expect(JSON.stringify(b.emit.mock.calls)).not.toContain('private-') }) @@ -46,7 +56,7 @@ it('missing identity does not discard an otherwise valid event', async () => { const b = await setup(true) b.getDeviceIdentity.mockRejectedValueOnce(new Error('credential store unavailable')) await b.ctx.productAnalytics.report({ eventName: 'plugin_add_button_click', timestamp: 100, attributes: {} }) - expect(b.emit.mock.calls[0]?.[0].attributes).toEqual({ app_version: 'test-version' }) + expect(b.emit.mock.calls[0]?.[0].attributes).toEqual({ ...environment, app_version: 'test-version' }) }) it('unload suppresses an identity lookup that settles after disposal', async () => { @@ -79,11 +89,21 @@ it('writes the selected event through the real exporter to an isolated collector ctx.provide('webServer', {} as never) await ctx.plugin(OTel) const exporter = await ctx.plugin(ProductTelemetry, TelemetryConfig({ endpoint: `http://127.0.0.1:${address.port}/v1/logs`, serviceName: 'test', serviceVersion: '1', compression: 'none' })) - await ctx.plugin(Analytics, { enabled: true }) + const analytics = await ctx.plugin(Analytics, { enabled: true }) await ctx.productAnalytics.report({ eventName: 'api_key_save_click', timestamp: 1_800_000_000_000, attributes: {} }) + await analytics.dispose() + vi.mocked(collectEnvironment).mockResolvedValueOnce({ ...environment, os_version_full: '10.0.26300.9458' }) + await ctx.plugin(Analytics, { enabled: true }) + await ctx.productAnalytics.report({ eventName: 'api_key_save_click', timestamp: 1_800_000_000_001, attributes: {} }) await exporter.dispose() expect(captures).toHaveLength(1) - expect(JSON.parse(captures[0]!)).toMatchObject({ resourceLogs: [{ scopeLogs: [{ logRecords: [{ eventName: 'api_key_save_click', timeUnixNano: '1800000000000000000' }] }] }] }) + expect(JSON.parse(captures[0]!)).toMatchObject({ resourceLogs: [{ scopeLogs: [{ logRecords: [ + { eventName: 'api_key_save_click', timeUnixNano: '1800000000000000000' }, + { eventName: 'api_key_save_click', timeUnixNano: '1800000000001000000' }, + ] }] }] }) + expect(captures[0]).toContain('10.0.26300.9457') + expect(captures[0]).toContain('10.0.26300.9458') + expect(collectEnvironment).toHaveBeenCalledTimes(2) expect(captures[0]).not.toContain('device_id') expect(captures[0]).not.toContain('app_version') }) @@ -159,3 +179,47 @@ it('wakes pending policy readers on disposal and registers hidden settings prese expect(await pending).toEqual({ done: true, value: undefined }) expect(dispose).toHaveBeenCalledOnce() }) + +it('shares one environment query across concurrent events and keeps identity fresh', async () => { + const b = await setup(true) + const pending = Promise.withResolvers() + vi.mocked(collectEnvironment).mockReturnValueOnce(pending.promise) + const first = b.ctx.productAnalytics.report({ eventName: 'auth_page_view', timestamp: 1, attributes: {} }) + b.getDeviceIdentity.mockResolvedValueOnce({ userId: 'user-2', osVersion: 'untrusted-os' }) + const second = b.ctx.productAnalytics.report({ eventName: 'desktop_app_launch', timestamp: 2, attributes: {} }) + expect(collectEnvironment).toHaveBeenCalledTimes(1) + expect(b.emit).not.toHaveBeenCalled() + pending.resolve(environment) + await Promise.all([first, second]) + expect(b.emit.mock.calls.map(([record]) => record.attributes?.user_id)).toEqual(['user-1', 'user-2']) + expect(b.emit.mock.calls.map(([record]) => record.attributes?.os_version_full)).toEqual(['10.0.26300.9457', '10.0.26300.9457']) + expect(b.emit.mock.calls).toHaveLength(2) + await b.ctx.productAnalytics.report({ eventName: 'auth_page_view', timestamp: 3, attributes: {} }) + expect(collectEnvironment).toHaveBeenCalledTimes(1) + expect(b.getDeviceIdentity).toHaveBeenCalledTimes(3) +}) + +it('caches failed environment fields without discarding the event or retrying each interaction', async () => { + const b = await setup(true) + vi.mocked(collectEnvironment).mockResolvedValue({ os_name: 'windows', app_arch: 'x64', missing_fields: { os_version_full: 'timeout' } }) + for (const timestamp of [1, 2]) await b.ctx.productAnalytics.report({ eventName: 'auth_page_view', timestamp, attributes: {} }) + expect(collectEnvironment).toHaveBeenCalledTimes(1) + expect(b.emit.mock.calls).toHaveLength(2) + expect(b.emit.mock.calls[0]![0].attributes?.missing_fields).toEqual({ os_version_full: 'timeout' }) +}) + +it('aborts and awaits environment sampling on disposal without emitting late results', async () => { + const b = await setup(true) + const pending = Promise.withResolvers() + vi.mocked(collectEnvironment).mockReturnValueOnce(pending.promise) + const report = b.ctx.productAnalytics.report({ eventName: 'desktop_app_launch', timestamp: 1, attributes: {} }) + const signal = vi.mocked(collectEnvironment).mock.calls[0]![0].signal + const aborted = new Promise((resolve) => { signal.addEventListener('abort', () => { resolve() }, { once: true }) }) + let disposed = false + const disposal = b.fiber.dispose().then(() => { disposed = true }) + await aborted + expect(disposed).toBe(false) + pending.resolve(environment) + await Promise.all([report, disposal]) + expect(b.emit).not.toHaveBeenCalled() +}) diff --git a/packages/client/product-analytics/tests/environment-composition.host.e2e.ts b/packages/client/product-analytics/tests/environment-composition.host.e2e.ts new file mode 100644 index 0000000000..6bb08a171a --- /dev/null +++ b/packages/client/product-analytics/tests/environment-composition.host.e2e.ts @@ -0,0 +1,43 @@ +import { readFile } from 'node:fs/promises' +import { resolve } from 'node:path' +import { platform } from 'node:os' +import { expect, it } from 'vitest' +import { z } from 'zod' +import { runLoaderSmoke, LOADER_SMOKE_TEST_TIMEOUT_MS } from '@deepseek-ai/dsh-loader-smoke' + +it('adds real system metadata to the existing event through Loader and OTLP', async () => { + const driver = resolve(import.meta.dirname, 'fixtures/environment.host.ts') + let captured: unknown + const attribute = z.object({ key: z.string(), value: z.object({ + stringValue: z.string().optional(), intValue: z.number().optional(), + kvlistValue: z.object({ values: z.array(z.unknown()) }).optional(), + }) }) + const record = z.object({ eventName: z.string(), attributes: z.array(attribute) }) + const batch = z.object({ resourceLogs: z.array(z.object({ scopeLogs: z.array(z.object({ logRecords: z.array(record) })) })) }) + const schema = z.object({ captures: z.array(batch), reference: z.record(z.string(), z.union([z.string(), z.number()])) }) + await runLoaderSmoke({ + label: 'Desktop environment attributes', tempDirPrefix: 'product-environment-', + binScript: driver, libBinScript: driver, + configPath: resolve(import.meta.dirname, 'fixtures/environment.patch.yml'), + tsconfigPath: resolve(import.meta.dirname, '../../../../tsconfig.base.json'), + inspect: async (cwd) => { captured = JSON.parse(await readFile(resolve(cwd, 'captures.json'), 'utf8')) }, + }) + const result = schema.parse(captured) + const records = result.captures.flatMap(batch => batch.resourceLogs + .flatMap(resource => resource.scopeLogs.flatMap(scope => scope.logRecords))) + expect(records).toHaveLength(1) + expect(records[0]!.eventName).toBe('desktop_app_launch') + const fields = Object.fromEntries(records[0]!.attributes.map(attribute => [attribute.key, attribute.value])) + for (const [key, value] of Object.entries(result.reference)) { + expect(fields[key]).toEqual(typeof value === 'number' ? { intValue: value } : { stringValue: value }) + } + if (platform() === 'win32' || platform() === 'darwin') { + expect(fields.missing_fields).toEqual({ kvlistValue: { values: [] } }) + expect(fields.cpu_model?.stringValue).toBeTruthy() + } + if (platform() === 'win32') expect(fields.os_version_full?.stringValue).toMatch(/^\d+\.\d+\.\d+\.\d+$/) + expect(fields.app_version).toEqual({ stringValue: 'synthetic-release' }) + expect(fields).not.toHaveProperty('device_id') + expect(fields).not.toHaveProperty('user_id') + expect(JSON.stringify(result.captures)).not.toMatch(/serial_number|hostname|device_uuid|environment_schema_version/) +}, LOADER_SMOKE_TEST_TIMEOUT_MS) diff --git a/packages/client/product-analytics/tests/environment-query.host.spec.ts b/packages/client/product-analytics/tests/environment-query.host.spec.ts new file mode 100644 index 0000000000..6b84b4ee76 --- /dev/null +++ b/packages/client/product-analytics/tests/environment-query.host.spec.ts @@ -0,0 +1,42 @@ +import { expect, it } from 'vitest' +import { queryEnvironment } from '../src/environment-query.ts' + +const options = () => ({ timeoutMillis: 5000, maxOutputBytes: 16384, signal: new AbortController().signal }) + +// These Node snippets are metadata-command stand-ins, with no Cordis or application boot. +it('captures UTF-8 output and closes the child before returning', async () => { + expect(await queryEnvironment(process.execPath, ['-e', "process.stdout.write('CPU 型号')"], options())) + .toEqual({ kind: 'value', stdout: 'CPU 型号' }) +}) + +it('omits nonzero command output and raw error text', async () => { + expect(await queryEnvironment(process.execPath, ['-e', "console.error('private error'); console.log('private output'); process.exitCode = 1"], options())) + .toEqual({ kind: 'missing', reason: 'query-failed' }) +}) + +it('settles a failed spawn without retaining an output buffer or child', async () => { + expect(await queryEnvironment(`${process.execPath}-nonexistent`, [], options())) + .toEqual({ kind: 'missing', reason: 'query-failed' }) +}) + +it('terminates a timed-out query and awaits close', async () => { + expect(await queryEnvironment(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { ...options(), timeoutMillis: 20 })) + .toEqual({ kind: 'missing', reason: 'timeout' }) +}) + +it('bounds multibyte stdout without treating truncated metadata as valid', async () => { + expect(await queryEnvironment(process.execPath, ['-e', "process.stdout.write('界'.repeat(10000))"], { ...options(), maxOutputBytes: 128 })) + .toEqual({ kind: 'missing', reason: 'output-limit' }) +}) + +it('bounds stderr even when stdout could describe valid metadata', async () => { + expect(await queryEnvironment(process.execPath, ['-e', "process.stderr.write('x'.repeat(10000))"], { ...options(), maxOutputBytes: 128 })) + .toEqual({ kind: 'missing', reason: 'output-limit' }) +}) + +it('aborts an in-flight query and waits for child closure', async () => { + const lifetime = new AbortController() + const result = queryEnvironment(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { ...options(), signal: lifetime.signal }) + lifetime.abort() + expect(await result).toEqual({ kind: 'missing', reason: 'query-failed' }) +}) diff --git a/packages/client/product-analytics/tests/environment.host.spec.ts b/packages/client/product-analytics/tests/environment.host.spec.ts new file mode 100644 index 0000000000..6fba26822d --- /dev/null +++ b/packages/client/product-analytics/tests/environment.host.spec.ts @@ -0,0 +1,179 @@ +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import * as os from 'node:os' +import { queryEnvironment } from '../src/environment-query.ts' +import { collectEnvironment } from '../src/environment.ts' + +vi.mock('node:os', async importOriginal => ({ ...await importOriginal(), + platform: vi.fn(), cpus: vi.fn(), totalmem: vi.fn(), release: vi.fn(), machine: vi.fn(), +})) +vi.mock('../src/environment-query.ts', () => ({ queryEnvironment: vi.fn() })) + +const options = () => ({ timeoutMillis: 5000, maxOutputBytes: 16384, signal: new AbortController().signal }) +const windows = { major: 10, minor: 0, build: '26300', revision: 9457 } +const processors = [{ cores: 8, architecture: 9 }, { cores: 6, architecture: 9 }] +const macVersion = 'ProductName: macOS\nProductVersion: 27.0.1\nBuildVersion: 26A434\n' +const macCpu = 'hw.physicalcpu: 8\nhw.optional.arm64: 1\n' + +beforeEach(() => { + vi.resetAllMocks() + vi.stubEnv('SystemRoot', 'C:\\Windows') + vi.mocked(os.platform).mockReturnValue('win32') + vi.mocked(os.release).mockReturnValue('10.0.26300') + vi.mocked(os.machine).mockReturnValue('x86_64') + vi.mocked(os.totalmem).mockReturnValue(32 * 1024 ** 3) + vi.mocked(os.cpus).mockReturnValue(Array.from({ length: 20 }, () => ({ + model: 'Fixture CPU', speed: 2400, times: { user: 0, nice: 0, sys: 0, idle: 0, irq: 0 }, + }))) +}) +afterEach(() => { vi.unstubAllEnvs() }) + +function outputs(version: unknown = windows, cpu: unknown = processors): void { + vi.mocked(queryEnvironment).mockResolvedValueOnce({ kind: 'value', stdout: JSON.stringify(version) }) + .mockResolvedValueOnce({ kind: 'value', stdout: JSON.stringify(cpu) }) +} + +it('preserves Windows revision and sums physical cores across sockets independently of logical count', async () => { + outputs() + expect(await collectEnvironment(options())).toEqual({ + os_name: 'windows', os_version: 'win32 10.0.26300', os_version_full: '10.0.26300.9457', os_build: '26300', + os_arch: 'x64', app_arch: process.arch, cpu_model: 'Fixture CPU', cpu_logical_count: 20, + cpu_physical_core_count: 14, memory_total_bytes: 34359738368, missing_fields: {}, + }) + for (const [command, args] of vi.mocked(queryEnvironment).mock.calls) { + expect(command).toContain('powershell.exe') + expect(args.slice(0, 3)).toEqual(['-NoProfile', '-NonInteractive', '-EncodedCommand']) + const script = Buffer.from(args[3]!, 'base64').toString('utf16le') + expect(script).not.toMatch(/UUID|SerialNumber|ProductId|Hostname|Win32_ComputerSystemProduct/i) + } +}) + +it.each([0, 9457, 9458])('retains a genuine Windows revision of %s', async (revision) => { + outputs({ ...windows, revision }) + expect((await collectEnvironment(options())).os_version_full).toBe(`10.0.26300.${revision}`) +}) + +it('does not invent revision zero when UBR is missing', async () => { + outputs({ ...windows, revision: null }) + const result = await collectEnvironment(options()) + expect(result.os_build).toBe('26300') + expect(result).not.toHaveProperty('os_version_full') + expect(result.missing_fields).toEqual({ os_version_full: 'unavailable' }) +}) + +it('marks missing build, major, and minor without discarding CPU metadata', async () => { + outputs({ major: null, minor: null, build: null, revision: null }) + const result = await collectEnvironment(options()) + expect(result.missing_fields).toEqual({ os_version_full: 'unavailable', os_build: 'unavailable' }) + expect(result.cpu_physical_core_count).toBe(14) +}) + +it.each([ + { major: -1, minor: 0, build: '26300', revision: 9457 }, + { ...windows, revision: '9457' }, { ...windows, build: '26300.9457' }, +])('rejects invalid Windows version JSON: %j', async (version) => { + outputs(version) + expect((await collectEnvironment(options())).missing_fields).toMatchObject({ os_version_full: 'invalid-value', os_build: 'invalid-value' }) +}) + +it.each([{ cpu: [] }, { cpu: [{ cores: 0, architecture: 9 }] }, { cpu: [{ cores: '8', architecture: 9 }] }])('rejects invalid CPU fields: %j', async ({ cpu }) => { + outputs(windows, cpu) + expect((await collectEnvironment(options())).missing_fields).toEqual({ os_arch: 'invalid-value', cpu_physical_core_count: 'invalid-value' }) +}) + +it.each([null, 99])('marks unavailable CPU architecture %s and physical cores', async (architecture) => { + outputs(windows, [{ cores: null, architecture }]) + const result = await collectEnvironment(options()) + expect(result.missing_fields).toEqual({ os_arch: 'unavailable', cpu_physical_core_count: 'unavailable' }) + expect(result).not.toHaveProperty('cpu_physical_core_count') +}) + +it('does not merge conflicting socket architectures', async () => { + outputs(windows, [{ cores: 4, architecture: 9 }, { cores: 4, architecture: 12 }]) + expect((await collectEnvironment(options())).missing_fields.os_arch).toBe('unavailable') +}) + +it('reads native Windows ARM64 independently of the application binary', async () => { + outputs(windows, [{ cores: 8, architecture: 12 }]) + const result = await collectEnvironment(options()) + expect(result.os_arch).toBe('arm64') + expect(result.app_arch).toBe(process.arch) +}) + +it.each([undefined, ''])('omits Windows query fields when SystemRoot is %s', async (root) => { + vi.stubEnv('SystemRoot', root) + const result = await collectEnvironment(options()) + expect(queryEnvironment).not.toHaveBeenCalled() + expect(result.missing_fields).toEqual({ os_version_full: 'unavailable', os_build: 'unavailable', os_arch: 'unavailable', cpu_physical_core_count: 'unavailable' }) +}) + +it.each(['timeout', 'output-limit', 'query-failed'] as const)('isolates %s from local CPU and memory fields', async (reason) => { + vi.mocked(queryEnvironment).mockResolvedValue({ kind: 'missing', reason }) + const result = await collectEnvironment(options()) + expect(result.cpu_model).toBe('Fixture CPU') + expect(result.memory_total_bytes).toBe(34359738368) + expect(result.missing_fields).toEqual({ os_version_full: reason, os_build: reason, os_arch: reason, cpu_physical_core_count: reason }) +}) + +it('rejects malformed JSON without exposing raw output', async () => { + vi.mocked(queryEnvironment).mockResolvedValue({ kind: 'value', stdout: 'private output' }) + const result = await collectEnvironment(options()) + expect(result.missing_fields.os_version_full).toBe('invalid-value') + expect(JSON.stringify(result)).not.toContain('private') +}) + +it.each(['1', '0'])('reports macOS product version and native architecture when arm64=%s', async (arm) => { + vi.mocked(os.platform).mockReturnValue('darwin') + vi.mocked(os.release).mockReturnValue('26.0.0') + vi.mocked(queryEnvironment).mockResolvedValueOnce({ kind: 'value', stdout: macVersion }) + .mockResolvedValueOnce({ kind: 'value', stdout: macCpu.replace('arm64: 1', `arm64: ${arm}`) }) + const result = await collectEnvironment(options()) + expect(result).toMatchObject({ os_name: 'macos', os_version: 'darwin 26.0.0', os_version_full: '27.0.1', os_build: '26A434', + os_arch: arm === '1' ? 'arm64' : 'x64', app_arch: process.arch, cpu_physical_core_count: 8, missing_fields: {} }) + expect(vi.mocked(queryEnvironment).mock.calls.map(([command, args]) => [command, args])).toEqual([ + ['/usr/bin/sw_vers', []], ['/usr/sbin/sysctl', ['-i', 'hw.physicalcpu', 'hw.optional.arm64']], + ]) +}) + +it('preserves macOS security response suffixes', async () => { + vi.mocked(os.platform).mockReturnValue('darwin') + vi.mocked(queryEnvironment).mockResolvedValueOnce({ kind: 'value', stdout: `${macVersion}ProductVersionExtra: (a)\n` }) + .mockResolvedValueOnce({ kind: 'value', stdout: macCpu }) + expect((await collectEnvironment(options())).os_version_full).toBe('27.0.1 (a)') +}) + +it.each(['', 'ProductVersion: garbage\nBuildVersion: private\nProductVersionExtra: invalid', + 'ProductVersion: 27.0.1\nBuildVersion: bad\nProductVersionExtra: invalid'])('rejects invalid macOS metadata: %s', async (stdout) => { + vi.mocked(os.platform).mockReturnValue('darwin') + vi.mocked(queryEnvironment).mockResolvedValueOnce({ kind: 'value', stdout }) + .mockResolvedValueOnce({ kind: 'value', stdout: 'hw.physicalcpu: 0\nhw.optional.arm64: unknown' }) + expect((await collectEnvironment(options())).missing_fields).toEqual({ + os_version_full: 'invalid-value', os_build: 'invalid-value', cpu_physical_core_count: 'invalid-value', os_arch: 'unavailable', + }) +}) + +it('reports macOS query failure independently of Node fields', async () => { + vi.mocked(os.platform).mockReturnValue('darwin') + vi.mocked(queryEnvironment).mockResolvedValue({ kind: 'missing', reason: 'timeout' }) + expect((await collectEnvironment(options())).missing_fields).toEqual({ os_version_full: 'timeout', os_build: 'timeout', os_arch: 'timeout', cpu_physical_core_count: 'timeout' }) +}) + +it.each([0, Number.NaN, 1.5])('marks unavailable memory %s instead of reporting zero', async (memory) => { + vi.mocked(os.totalmem).mockReturnValue(memory) + vi.mocked(os.cpus).mockReturnValue([]) + outputs() + const result = await collectEnvironment(options()) + expect(result).not.toHaveProperty('memory_total_bytes') + expect(result.missing_fields).toMatchObject({ memory_total_bytes: 'unavailable', cpu_model: 'unavailable', cpu_logical_count: 'unavailable' }) +}) + +it('isolates refused synchronous OS sources', async () => { + for (const source of [os.totalmem, os.cpus, os.release]) vi.mocked(source).mockImplementation(() => { throw new Error('source refused') }) + outputs() + expect((await collectEnvironment(options())).missing_fields).toMatchObject({ os_version: 'unavailable', memory_total_bytes: 'unavailable', cpu_model: 'unavailable', cpu_logical_count: 'unavailable' }) +}) + +it('marks unsupported platform queries without spawning commands', async () => { + vi.mocked(os.platform).mockReturnValue('linux') + expect((await collectEnvironment(options())).missing_fields).toEqual({ os_version_full: 'unsupported', os_build: 'unsupported', cpu_physical_core_count: 'unsupported' }) + expect(queryEnvironment).not.toHaveBeenCalled() +}) diff --git a/packages/client/product-analytics/tests/fixtures/environment.host.ts b/packages/client/product-analytics/tests/fixtures/environment.host.ts new file mode 100644 index 0000000000..a19cd60755 --- /dev/null +++ b/packages/client/product-analytics/tests/fixtures/environment.host.ts @@ -0,0 +1,66 @@ +/** Real Host environment collection through the shipped profile and isolated OTLP receiver. */ +import { createServer } from 'node:http' +import { once } from 'node:events' +import { writeFile } from 'node:fs/promises' +import { execFile } from 'node:child_process' +import { totalmem, cpus, platform } from 'node:os' +import { promisify } from 'node:util' +import { join } from 'node:path' +import type {} from '@deepseek-ai/dsh-client-product-analytics' +import { bootProductionProfile } from '../../../../test-support/loader-smoke/tests/fixtures/production-profile.ts' + +const overlay = process.argv[2] +if (overlay === undefined) throw new Error('environment fixture requires its overlay') +const captures: unknown[] = [] +const receiver = createServer((req, res) => { + const chunks: Buffer[] = [] + req.on('data', (chunk: Buffer) => { chunks.push(chunk) }) + req.on('end', () => { captures.push(JSON.parse(Buffer.concat(chunks).toString())); res.end('{}') }) +}) +const run = promisify(execFile) +const reference: Record = { memory_total_bytes: totalmem(), cpu_logical_count: cpus().length } +try { + receiver.listen(0, '127.0.0.1') + await once(receiver, 'listening') + const address = receiver.address() + if (address === null || typeof address === 'string') throw new Error('environment collector has no port') + process.env.DSH_PRODUCT_TELEMETRY_TEST_ENDPOINT = `http://127.0.0.1:${address.port}/logs` + const ctx = await bootProductionProfile({ binName: 'environment-test', profile: 'headless', overlayPaths: [overlay], + prepare: (root) => { root.provide('deepseekAccount', { getDeviceIdentity: async () => undefined } as never) }, + }) + try { + const analytics = ctx.get('productAnalytics') + if (analytics === undefined) throw new Error('environment analytics did not mount') + await analytics.report({ eventName: 'desktop_app_launch', timestamp: 1_800_000_000_000, attributes: {} }) + } finally { await ctx.fiber.dispose() } + if (platform() === 'darwin') { + reference.os_version_full = (await run('/usr/bin/sw_vers', ['-productVersion'])).stdout.trim() + reference.os_build = (await run('/usr/bin/sw_vers', ['-buildVersion'])).stdout.trim() + reference.cpu_physical_core_count = Number((await run('/usr/sbin/sysctl', ['-n', 'hw.physicalcpu'])).stdout.trim()) + reference.os_arch = (await run('/usr/sbin/sysctl', ['-n', 'hw.optional.arm64'])).stdout.trim() === '1' ? 'arm64' : 'x64' + } else if (platform() === 'win32') { + const systemRoot = process.env.SystemRoot + if (systemRoot === undefined) throw new Error('Windows fixture requires SystemRoot') + const script = ` +$r = Get-ItemProperty 'HKLM:\\SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion' -Name CurrentMajorVersionNumber,CurrentMinorVersionNumber,CurrentBuildNumber,UBR +Write-Output "$($r.CurrentMajorVersionNumber).$($r.CurrentMinorVersionNumber).$($r.CurrentBuildNumber).$($r.UBR)" +Write-Output $r.CurrentBuildNumber +Write-Output ((Get-CimInstance Win32_Processor | Measure-Object NumberOfCores -Sum).Sum) +Write-Output (Get-CimInstance Win32_Processor | Select-Object -First 1 -ExpandProperty Architecture) +` + const { stdout } = await run(join(systemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'), + ['-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from(script, 'utf16le').toString('base64')]) + const [version, build, cores, architecture] = stdout.trim().split(/\r?\n/) + if (version === undefined || build === undefined) throw new Error('Windows fixture returned no version') + reference.os_version_full = version + reference.os_build = build + reference.cpu_physical_core_count = Number(cores) + reference.os_arch = ({ 0: 'ia32', 5: 'arm', 9: 'x64', 12: 'arm64' } as Record)[Number(architecture)]! + } + await writeFile('captures.json', JSON.stringify({ captures, reference })) +} finally { + const closed = once(receiver, 'close') + receiver.close() + receiver.closeAllConnections() + await closed +} diff --git a/packages/client/product-analytics/tests/fixtures/environment.patch.yml b/packages/client/product-analytics/tests/fixtures/environment.patch.yml new file mode 100644 index 0000000000..8d150bedc1 --- /dev/null +++ b/packages/client/product-analytics/tests/fixtures/environment.patch.yml @@ -0,0 +1,19 @@ +- id: session-telemetry-otel + disabled: true +- id: headless-startup + disabled: true +- id: headless-runner + disabled: true +- insert: + - id: product-telemetry-otel + name: '@deepseek-ai/dsh-host-product-telemetry-otel' + config: + endpoint: !!js process.env.DSH_PRODUCT_TELEMETRY_TEST_ENDPOINT + serviceName: dsh-environment-test + serviceVersion: synthetic-release + compression: none + - id: product-analytics + name: '@deepseek-ai/dsh-client-product-analytics' + config: + enabled: true + appVersion: synthetic-release diff --git a/packages/client/product-analytics/tsconfig.host.json b/packages/client/product-analytics/tsconfig.host.json index 487bb221ac..b4b4e831ab 100644 --- a/packages/client/product-analytics/tsconfig.host.json +++ b/packages/client/product-analytics/tsconfig.host.json @@ -7,7 +7,9 @@ }, "files": [ "src/index.ts", - "src/events.ts" + "src/events.ts", + "src/environment.ts", + "src/environment-query.ts" ], "references": [ { diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 798e310219..708eee6876 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -1797,7 +1797,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: '@Remote async report(event: ProductEvent): Promise', - description: 'Submit selected Desktop fields; missing identity is omitted and never generated.', + description: 'Submit selected Desktop fields with cached static environment and fresh identity. The first intake waits for bounded environment queries; concurrent intakes share them. Missing identity is omitted; failed environment fields carry fixed reasons.', parameters: [{ name: 'event', description: 'typed product event without message contents or credentials.' }], returns: 'after local submission; no delivery or warehouse acknowledgement.', }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 977eeaa9a8..ed7aa7dd8b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3175,6 +3175,9 @@ importers: '@deepseek-ai/dsh-api-gateway': specifier: workspace:* version: link:../../api/gateway + '@deepseek-ai/dsh-app-boot': + specifier: workspace:* + version: link:../../boot/app-boot '@deepseek-ai/dsh-compaction': specifier: workspace:* version: link:../../compaction/compaction @@ -3190,6 +3193,9 @@ importers: '@deepseek-ai/dsh-llm': specifier: workspace:* version: link:../../llm/llm + '@deepseek-ai/dsh-loader-smoke': + specifier: workspace:* + version: link:../../test-support/loader-smoke '@deepseek-ai/dsh-otel': specifier: workspace:* version: link:../../telemetry/otel diff --git a/tsconfig.client.json b/tsconfig.client.json index fc87ea5394..5d7035ed6f 100644 --- a/tsconfig.client.json +++ b/tsconfig.client.json @@ -48,7 +48,9 @@ // belongs to the host aggregate, which excludes this program's `*.client.*` // in turn. `exclude` wins over `include`, so the test glob above stays broad. "exclude": [ - "packages/client/*/tests/**/*.host.spec.ts" + "packages/client/*/tests/**/*.host.spec.ts", + "packages/client/*/tests/**/*.host.e2e.ts", + "packages/client/*/tests/**/*.host.ts" ], "references": [ { "path": "./packages/experimental/client-ui-cot-translation/tsconfig.client.json" }, From 04c94890658c3f87a93deb9d6396402d44755818 Mon Sep 17 00:00:00 2001 From: 07akioni <07akioni2@gmail.com> Date: Wed, 14 Oct 2026 16:10:40 +0800 Subject: [PATCH 2/2] fix(telemetry): handle native metadata edge cases and scalar failures --- docs/subsystems/product-telemetry.i18n.yaml | 4 +-- docs/subsystems/product-telemetry.md | 2 +- docs/subsystems/product-telemetry.zh.md | 2 +- .../client/product-analytics/README.i18n.yaml | 8 ++--- packages/client/product-analytics/README.md | 10 ++++--- .../client/product-analytics/README.zh.md | 10 ++++--- .../src/environment-query.ts | 26 +++++++++------- .../product-analytics/src/environment.ts | 20 +++++++------ .../client/product-analytics/src/index.ts | 6 +++- .../tests/collection.host.spec.ts | 25 +++++++++++++--- .../tests/environment-composition.host.e2e.ts | 5 ++-- .../tests/environment-query.host.spec.ts | 30 ++++++++++++++++++- .../tests/environment.host.spec.ts | 23 +++++++++++--- .../tests/fixtures/environment.host.ts | 8 +++-- tsconfig.client.json | 4 +-- 15 files changed, 131 insertions(+), 52 deletions(-) diff --git a/docs/subsystems/product-telemetry.i18n.yaml b/docs/subsystems/product-telemetry.i18n.yaml index 28b4e64b14..0ad595e309 100644 --- a/docs/subsystems/product-telemetry.i18n.yaml +++ b/docs/subsystems/product-telemetry.i18n.yaml @@ -3,8 +3,8 @@ # After editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/product-telemetry.md /product-telemetry: - en: 04e9a32dfbe69355 - zh: 0e6c78274dd97466 + en: 419a470ec1cce036 + zh: 91e69fb502318e45 /product-telemetry/record-types: en: bf5eadedbc896f5a zh: 158fce1609a85d6d diff --git a/docs/subsystems/product-telemetry.md b/docs/subsystems/product-telemetry.md index 9d656631f6..14cf59985b 100644 --- a/docs/subsystems/product-telemetry.md +++ b/docs/subsystems/product-telemetry.md @@ -2,7 +2,7 @@ English | [中文](product-telemetry.zh.md) -The [product telemetry plugin](../../packages/host/product-telemetry-otel/README.md) sends explicitly selected analytics events through OTLP/HTTP. Its `productTelemetry` service owns submission only; product consumers own when an event occurs and which fields are approved. The exporter collects no Session data or identifiers automatically. The [Desktop analytics consumer](../../packages/client/product-analytics/README.md) selects interaction and live compaction events, adds available login identity, and follows the Desktop launch-time collection switch. Ordinary Web clients do not collect product events. +The [product telemetry plugin](../../packages/host/product-telemetry-otel/README.md) sends explicitly selected analytics events through OTLP/HTTP. Its `productTelemetry` service owns submission only; product consumers own when an event occurs and which fields are approved. The exporter collects no Session data or identifiers automatically. The [Desktop analytics consumer](../../packages/client/product-analytics/README.md) selects interaction and live compaction events, adds cached static environment and available login identity, and follows the Desktop launch-time collection switch. Ordinary Web clients do not collect product events. `ProductTelemetryRecord` requires an event name, string body, and Unix-millisecond timestamp. Attributes accept scalar strings, numbers, booleans, and one-level objects of those values (`ProductTelemetryScalar`). Optional severity uses OTel severity numbers and defaults to INFO. Observation time is assigned when the record is enqueued. diff --git a/docs/subsystems/product-telemetry.zh.md b/docs/subsystems/product-telemetry.zh.md index e8072807ec..feffdfae91 100644 --- a/docs/subsystems/product-telemetry.zh.md +++ b/docs/subsystems/product-telemetry.zh.md @@ -2,7 +2,7 @@ [English](product-telemetry.md) | 中文 -[产品埋点插件](../../packages/host/product-telemetry-otel/README.zh.md) 通过 OTLP/HTTP 发送明确选定的分析事件。`productTelemetry` 服务仅负责提交;产品消费方决定事件发生时机与获准采集的字段。导出器不自动采集 Session 数据或标识。[桌面埋点消费方](../../packages/client/product-analytics/README.zh.md)选择交互与实时压缩事件,补充可用的登录身份,并遵守桌面启动时的采集开关。普通 Web 客户端不采集产品事件。 +[产品埋点插件](../../packages/host/product-telemetry-otel/README.zh.md) 通过 OTLP/HTTP 发送明确选定的分析事件。`productTelemetry` 服务仅负责提交;产品消费方决定事件发生时机与获准采集的字段。导出器不自动采集 Session 数据或标识。[桌面埋点消费方](../../packages/client/product-analytics/README.zh.md)选择交互与实时压缩事件,补充缓存的静态环境和可用的登录身份,并遵守桌面启动时的采集开关。普通 Web 客户端不采集产品事件。 `ProductTelemetryRecord` 要求事件名称、字符串 body 和 Unix 毫秒时间戳。属性接受字符串、数字、布尔标量,以及这些值组成的单层对象(`ProductTelemetryScalar`)。可选的严重程度使用 OTel 严重程度数字,默认为 INFO。记录入队时填写观测时间。 diff --git a/packages/client/product-analytics/README.i18n.yaml b/packages/client/product-analytics/README.i18n.yaml index 989c03c5b2..546332a531 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: 062e1847cfcc60ee - zh: ea1b6cc2d2413226 + en: 042e8eef8647b025 + zh: 256ff24fee965eda /deepseek-ai-dsh-client-product-analytics/understand-the-implementation: en: 96b1169d78f4c761 zh: 475df4ea6ed1cb06 @@ -27,8 +27,8 @@ en: 1f5125831808e7a9 zh: 97566fdde332df39 /deepseek-ai-dsh-client-product-analytics/known-limitations-and-deferred-work: - en: a33ad31745fd5240 - zh: 8b579cfc4c9b8155 + en: 47bd8bf635e5edbb + zh: defee1a5d6764bfe /deepseek-ai-dsh-client-product-analytics/known-limitations-and-deferred-work/dev-note: en: 0a1253c6f7b5e8b9 zh: 9f56528562c7c914 diff --git a/packages/client/product-analytics/README.md b/packages/client/product-analytics/README.md index 1317bcd5fe..54cd8a820f 100644 --- a/packages/client/product-analytics/README.md +++ b/packages/client/product-analytics/README.md @@ -29,19 +29,19 @@ Every product event carries available `device_id`, `user_id`, and `app_version` | Field | Meaning and source | |---|---| -| `os_name` | `windows` or `macos`; Node platform selects the mapping | +| `os_name` | `windows` or `macos`; other platforms retain the raw Node platform name (such as `linux`) | | `os_version` | Legacy ` ` from `node:os.release()` | | `os_version_full` | Windows registry major/minor/build plus `UBR` revision; macOS `sw_vers` product version including a security-response suffix when present | | `os_build` | Windows registry `CurrentBuildNumber`; macOS `sw_vers` build | -| `os_arch` | Native architecture mapped to `ia32`, `x64`, `arm`, or `arm64`; Windows processor CIM architecture or macOS `hw.optional.arm64`, independent of application emulation | +| `os_arch` | Native architecture mapped to `ia32`, `x64`, `arm`, or `arm64`; Windows processor CIM architecture or macOS `hw.optional.arm64` (`1` means ARM64; an absent key means Intel x64), independent of application emulation | | `app_arch` | Running binary architecture from `process.arch` | | `cpu_model` | First logical processor's model from `node:os.cpus()` | | `cpu_logical_count` | Number of system-reported logical processors from `node:os.cpus()`, not application parallelism | | `cpu_physical_core_count` | Sum of Windows processor CIM `NumberOfCores` across sockets; macOS `hw.physicalcpu` | | `memory_total_bytes` | Total physical memory from `node:os.totalmem()`, in bytes without rounding | -| `missing_fields` | Object mapping omitted environment field names to `unavailable`, `invalid-value`, `query-failed`, `timeout`, `output-limit`, or `unsupported`; empty when all fields are available | +| `missing_fields` | Sorted comma-separated `field=reason` pairs using `unavailable`, `invalid-value`, `query-failed`, `timeout`, `output-limit`, or `unsupported`; omitted when all fields are available | -The first enabled intake collects one environment snapshot; concurrent and later intakes reuse its successful fields and failures until the plugin remounts or the Host restarts. Intake waits for sampling while callers submit asynchronously; startup does not await collection. Identity changes remain visible on subsequent events. Each metadata command has a configurable `environmentQueryTimeoutMillis` deadline (5,000 ms) and `environmentQueryMaxOutputBytes` bound per output stream (16,384 bytes). Disposal cancels commands and waits for child closure; disabled or disposed intake never emits late results. Missing revision never becomes `.0`, and missing numeric fields never become zero. Only Windows and macOS have full-version and physical-core queries; other platforms mark those fields `unsupported`. +The first enabled intake collects one environment snapshot; concurrent and later intakes reuse its successful fields and failures until the plugin remounts or the Host restarts. Intake waits for sampling while callers submit asynchronously; startup does not await collection. Identity changes remain visible on subsequent events. Each metadata command has a configurable `environmentQueryTimeoutMillis` deadline (5,000 ms) and `environmentQueryMaxOutputBytes` bound per output stream (16,384 bytes). Disposal cancels commands and waits for child closure; disabled or disposed intake never emits late results. Missing revision never becomes `.0`, and missing numeric fields never become zero. Only Windows and macOS have full-version, native-architecture, and physical-core queries; other platforms mark those fields `unsupported`. Electron forwards its build-inlined `DSH_CLIENT_VERSION` to the Host; analytics and the exporter reuse this client version. The exporter requires it. The [Desktop composition](../../bundle/web-app/README.md) owns batching and timeout settings, including cancellation at the shutdown deadline. @@ -74,6 +74,8 @@ None; collection does not modify model requests. Delivery is best effort. +- Environment sampling is one-shot: startup contention can cause a cached timeout for the whole run. `missing_fields` describes sampling failure, not hardware absence; a later event does not retry. Deployments can increase the query deadline; remount or restart samples again. +- Host security policies can block or terminate the fixed PowerShell queries, producing `query-failed` on managed Windows machines. - Disabling stops new collection; it does not clear the exporter queue. - Renderer failures are discarded, and the exporter has no durable outbox or warehouse acknowledgement. - Native startup reporting waits for Host authentication; a process that fails before Host readiness cannot report its launch. diff --git a/packages/client/product-analytics/README.zh.md b/packages/client/product-analytics/README.zh.md index c5d588b851..803494c0a5 100644 --- a/packages/client/product-analytics/README.zh.md +++ b/packages/client/product-analytics/README.zh.md @@ -29,19 +29,19 @@ kind: "package-reference" | 字段 | 含义与来源 | |---|---| -| `os_name` | `windows` 或 `macos`;由 Node 平台选择映射 | +| `os_name` | `windows` 或 `macos`;其他平台保留原始 Node 平台名称(如 `linux`) | | `os_version` | 原有的 ` <内核版本>`,来自 `node:os.release()` | | `os_version_full` | Windows 注册表 major/minor/build 加上 `UBR` 修订号;macOS `sw_vers` 产品版本,包括存在时的安全响应后缀 | | `os_build` | Windows 注册表 `CurrentBuildNumber`;macOS `sw_vers` build | -| `os_arch` | 原生架构映射为 `ia32`、`x64`、`arm` 或 `arm64`;来自 Windows 处理器 CIM 架构或 macOS `hw.optional.arm64`,独立于应用仿真运行 | +| `os_arch` | 原生架构映射为 `ia32`、`x64`、`arm` 或 `arm64`;来自 Windows 处理器 CIM 架构或 macOS `hw.optional.arm64`(`1` 表示 ARM64,缺少键表示 Intel x64),独立于应用仿真运行 | | `app_arch` | 来自 `process.arch` 的运行二进制架构 | | `cpu_model` | 来自 `node:os.cpus()` 的首个逻辑处理器型号 | | `cpu_logical_count` | 来自 `node:os.cpus()` 的系统报告逻辑处理器数量,不表示应用可用并行度 | | `cpu_physical_core_count` | Windows 各 socket 处理器 CIM `NumberOfCores` 之和;macOS `hw.physicalcpu` | | `memory_total_bytes` | 来自 `node:os.totalmem()` 的总物理内存,单位 bytes,不取整 | -| `missing_fields` | 对象,将省略的环境字段名映射到 `unavailable`、`invalid-value`、`query-failed`、`timeout`、`output-limit` 或 `unsupported`;全部字段可用时为空 | +| `missing_fields` | 按字段名排序、逗号分隔的 `field=reason` 项,原因使用 `unavailable`、`invalid-value`、`query-failed`、`timeout`、`output-limit` 或 `unsupported`;全部字段可用时省略 | -首次启用状态下的事件接收采集一份环境快照;并发和后续事件复用成功字段及失败状态,直到插件重新挂载或 Host 重启。事件接收等待采样,调用方异步提交;启动不等待采集。后续事件仍能反映身份变化。每条元数据命令具有可配置的 `environmentQueryTimeoutMillis` 期限(5,000 毫秒)和每路输出的 `environmentQueryMaxOutputBytes` 上限(16,384 bytes)。卸载取消命令并等待子进程关闭;禁用或卸载后的事件接收不会发送迟到结果。缺失修订号不会变为 `.0`,缺失数字字段不会变为零。只有 Windows 和 macOS 查询完整版本与物理核心数;其他平台将这些字段标记为 `unsupported`。 +首次启用状态下的事件接收采集一份环境快照;并发和后续事件复用成功字段及失败状态,直到插件重新挂载或 Host 重启。事件接收等待采样,调用方异步提交;启动不等待采集。后续事件仍能反映身份变化。每条元数据命令具有可配置的 `environmentQueryTimeoutMillis` 期限(5,000 毫秒)和每路输出的 `environmentQueryMaxOutputBytes` 上限(16,384 bytes)。卸载取消命令并等待子进程关闭;禁用或卸载后的事件接收不会发送迟到结果。缺失修订号不会变为 `.0`,缺失数字字段不会变为零。只有 Windows 和 macOS 查询完整版本、原生架构与物理核心数;其他平台将这些字段标记为 `unsupported`。 Electron 将构建内联的 `DSH_CLIENT_VERSION` 传给 Host;埋点与导出器复用这一客户端版本,导出器要求该值存在。[桌面组合](../../bundle/web-app/README.zh.md)负责批量发送与超时配置,包括达到关闭期限时的取消行为。 @@ -74,6 +74,8 @@ Electron 将构建内联的 `DSH_CLIENT_VERSION` 传给 Host;埋点与导出 交付采用尽力而为的方式。 +- 环境仅采样一次:启动期间的资源争用可能导致超时,并缓存到本次运行结束。`missing_fields` 表示采样失败,不表示硬件缺失;后续事件不重试。部署可提高查询期限;重新挂载或重启会再次采样。 +- 主机安全策略可能阻止或终止固定的 PowerShell 查询,使受管理的 Windows 机器产生 `query-failed`。 - 关闭只停止新采集,不清空导出器队列。 - 渲染进程的发送失败会被丢弃,导出器没有持久化待发队列或数据仓库确认。 - 原生启动事件等待 Host 认证完成;若进程在 Host 就绪前失败,则无法上报启动事件。 diff --git a/packages/client/product-analytics/src/environment-query.ts b/packages/client/product-analytics/src/environment-query.ts index 8086fff82f..ae0eb79551 100644 --- a/packages/client/product-analytics/src/environment-query.ts +++ b/packages/client/product-analytics/src/environment-query.ts @@ -1,5 +1,5 @@ /** Bounded, cancellable queries of fixed operating-system metadata commands. */ -import { execFile } from 'node:child_process' +import { execFile, type ChildProcess } from 'node:child_process' /** Fixed reasons exposed instead of command output or raw errors. */ export type MissingReason = 'unavailable' | 'invalid-value' | 'query-failed' | 'timeout' | 'output-limit' | 'unsupported' @@ -26,15 +26,21 @@ export interface QueryOptions { */ export async function queryEnvironment(command: string, args: readonly string[], options: QueryOptions): Promise { const result = Promise.withResolvers() - const child = execFile(command, [...args], { - encoding: 'utf8', windowsHide: true, signal: options.signal, killSignal: 'SIGKILL', - timeout: options.timeoutMillis, maxBuffer: options.maxOutputBytes, - }, (error, stdout) => { - if (error === null) { result.resolve({ kind: 'value', stdout }); return } - const reason = error.code === 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER' ? 'output-limit' - : error.killed && !options.signal.aborted ? 'timeout' : 'query-failed' - result.resolve({ kind: 'missing', reason }) - }) + let child: ChildProcess + try { + child = execFile(command, [...args], { + encoding: 'utf8', windowsHide: true, signal: options.signal, killSignal: 'SIGKILL', + timeout: options.timeoutMillis, maxBuffer: options.maxOutputBytes, + }, (error, stdout) => { + if (error === null) { result.resolve({ kind: 'value', stdout }); return } + const reason = error.code === 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER' ? 'output-limit' + : error.killed && !options.signal.aborted ? 'timeout' : 'query-failed' + result.resolve({ kind: 'missing', reason }) + }) + } catch (_error) { + // Synchronous OS spawn failures create no child; other queries remain owned. + return { kind: 'missing', reason: 'query-failed' } + } // execFile can report abort before stdio closes; disposal owns both settlements. const closed = new Promise((resolve) => { child.once('close', () => { resolve() }) }) const [output] = await Promise.all([result.promise, closed]) diff --git a/packages/client/product-analytics/src/environment.ts b/packages/client/product-analytics/src/environment.ts index 7f067cc7b8..60ecd91b9e 100644 --- a/packages/client/product-analytics/src/environment.ts +++ b/packages/client/product-analytics/src/environment.ts @@ -1,5 +1,5 @@ /** Static Desktop environment fields, independent of account and credential availability. */ -import { cpus, machine, platform, release, totalmem } from 'node:os' +import { cpus, platform, release, totalmem } from 'node:os' import { join } from 'node:path' import { z } from 'zod' import { queryEnvironment, type MissingReason, type QueryOptions, type QueryResult } from './environment-query.ts' @@ -27,7 +27,7 @@ export interface EnvironmentAttributes { /** Total physical memory in bytes. */ memory_total_bytes?: number /** Only failed fields, with no raw commands, output, or errors. */ - missing_fields: Record + missing_fields: Partial> } type EnvironmentField = Exclude @@ -126,9 +126,12 @@ export async function collectEnvironment(options: QueryOptions): Promise machine()) - missing(['os_version_full', 'os_build', 'cpu_physical_core_count'], 'unsupported') + missing(['os_version_full', 'os_build', 'os_arch', 'cpu_physical_core_count'], 'unsupported') } return fields } diff --git a/packages/client/product-analytics/src/index.ts b/packages/client/product-analytics/src/index.ts index ce497f99df..86a08ef60a 100644 --- a/packages/client/product-analytics/src/index.ts +++ b/packages/client/product-analytics/src/index.ts @@ -109,11 +109,15 @@ export default class ProductAnalytics extends TypertRemoteService { this.environment, this.ctx.deepseekAccount.getDeviceIdentity().catch(() => undefined), ]) if (!this.enabled()) return + const { missing_fields: missingFields, ...metadata } = environment + const missing = Object.entries(missingFields).sort(([a], [b]) => a.localeCompare(b)) + .map(([field, reason]) => `${field}=${reason}`).join(',') this.ctx.productTelemetry.emit({ ...event, body: event.eventName, attributes: { ...event.attributes, - ...environment, + ...metadata, + ...missing === '' ? {} : { missing_fields: missing }, ...identity?.deviceId === undefined ? {} : { device_id: identity.deviceId }, ...identity?.userId === undefined ? {} : { user_id: identity.userId }, ...this.config.appVersion === undefined ? {} : { app_version: this.config.appVersion }, diff --git a/packages/client/product-analytics/tests/collection.host.spec.ts b/packages/client/product-analytics/tests/collection.host.spec.ts index 5538f4c513..1ccd331183 100644 --- a/packages/client/product-analytics/tests/collection.host.spec.ts +++ b/packages/client/product-analytics/tests/collection.host.spec.ts @@ -15,6 +15,7 @@ const environment: EnvironmentAttributes = { os_arch: 'arm64', app_arch: 'x64', cpu_model: 'Fixture CPU', cpu_logical_count: 16, cpu_physical_core_count: 8, memory_total_bytes: 34359738368, missing_fields: {}, } +const { missing_fields: _missingFields, ...commonEnvironment } = environment beforeEach(() => { vi.resetAllMocks(); vi.mocked(collectEnvironment).mockResolvedValue(environment) }) const cleanup: (() => Promise)[] = [] @@ -47,16 +48,17 @@ it('reuses login identity and copies only approved common fields', async () => { expect(b.getDeviceIdentity).toHaveBeenCalledWith() expect(b.emit).toHaveBeenCalledExactlyOnceWith({ eventName: 'auth_page_click', body: 'auth_page_click', timestamp: 100, - attributes: { ...environment, button_name: 'sign_in', device_id: 'login-device', user_id: 'user-1', app_version: 'test-version' }, + attributes: { ...commonEnvironment, button_name: 'sign_in', device_id: 'login-device', user_id: 'user-1', app_version: 'test-version' }, }) expect(JSON.stringify(b.emit.mock.calls)).not.toContain('private-') + expect(b.emit.mock.calls[0]![0].attributes).not.toHaveProperty('missing_fields') }) it('missing identity does not discard an otherwise valid event', async () => { const b = await setup(true) b.getDeviceIdentity.mockRejectedValueOnce(new Error('credential store unavailable')) await b.ctx.productAnalytics.report({ eventName: 'plugin_add_button_click', timestamp: 100, attributes: {} }) - expect(b.emit.mock.calls[0]?.[0].attributes).toEqual({ ...environment, app_version: 'test-version' }) + expect(b.emit.mock.calls[0]?.[0].attributes).toEqual({ ...commonEnvironment, app_version: 'test-version' }) }) it('unload suppresses an identity lookup that settles after disposal', async () => { @@ -92,7 +94,10 @@ it('writes the selected event through the real exporter to an isolated collector const analytics = await ctx.plugin(Analytics, { enabled: true }) await ctx.productAnalytics.report({ eventName: 'api_key_save_click', timestamp: 1_800_000_000_000, attributes: {} }) await analytics.dispose() - vi.mocked(collectEnvironment).mockResolvedValueOnce({ ...environment, os_version_full: '10.0.26300.9458' }) + const { cpu_physical_core_count: _cores, ...partialEnvironment } = environment + vi.mocked(collectEnvironment).mockResolvedValueOnce({ ...partialEnvironment, os_version_full: '10.0.26300.9458', + missing_fields: { cpu_physical_core_count: 'timeout' }, + }) await ctx.plugin(Analytics, { enabled: true }) await ctx.productAnalytics.report({ eventName: 'api_key_save_click', timestamp: 1_800_000_000_001, attributes: {} }) await exporter.dispose() @@ -103,6 +108,8 @@ it('writes the selected event through the real exporter to an isolated collector ] }] }] }) expect(captures[0]).toContain('10.0.26300.9457') expect(captures[0]).toContain('10.0.26300.9458') + expect(captures[0]).toContain('"stringValue":"cpu_physical_core_count=timeout"') + expect(captures[0]).not.toContain('kvlistValue') expect(collectEnvironment).toHaveBeenCalledTimes(2) expect(captures[0]).not.toContain('device_id') expect(captures[0]).not.toContain('app_version') @@ -205,7 +212,7 @@ it('caches failed environment fields without discarding the event or retrying ea for (const timestamp of [1, 2]) await b.ctx.productAnalytics.report({ eventName: 'auth_page_view', timestamp, attributes: {} }) expect(collectEnvironment).toHaveBeenCalledTimes(1) expect(b.emit.mock.calls).toHaveLength(2) - expect(b.emit.mock.calls[0]![0].attributes?.missing_fields).toEqual({ os_version_full: 'timeout' }) + expect(b.emit.mock.calls[0]![0].attributes?.missing_fields).toBe('os_version_full=timeout') }) it('aborts and awaits environment sampling on disposal without emitting late results', async () => { @@ -223,3 +230,13 @@ it('aborts and awaits environment sampling on disposal without emitting late res await Promise.all([report, disposal]) expect(b.emit).not.toHaveBeenCalled() }) + + +it('encodes failed fields as a sorted scalar and omits an empty failure list', async () => { + const b = await setup(true) + vi.mocked(collectEnvironment).mockResolvedValueOnce({ ...environment, + missing_fields: { os_version_full: 'timeout', cpu_physical_core_count: 'query-failed' }, + }) + await b.ctx.productAnalytics.report({ eventName: 'auth_page_view', timestamp: 1, attributes: {} }) + expect(b.emit.mock.calls[0]![0].attributes?.missing_fields).toBe('cpu_physical_core_count=query-failed,os_version_full=timeout') +}) diff --git a/packages/client/product-analytics/tests/environment-composition.host.e2e.ts b/packages/client/product-analytics/tests/environment-composition.host.e2e.ts index 6bb08a171a..e4b1c651ae 100644 --- a/packages/client/product-analytics/tests/environment-composition.host.e2e.ts +++ b/packages/client/product-analytics/tests/environment-composition.host.e2e.ts @@ -10,8 +10,7 @@ it('adds real system metadata to the existing event through Loader and OTLP', as let captured: unknown const attribute = z.object({ key: z.string(), value: z.object({ stringValue: z.string().optional(), intValue: z.number().optional(), - kvlistValue: z.object({ values: z.array(z.unknown()) }).optional(), - }) }) + }).strict() }) const record = z.object({ eventName: z.string(), attributes: z.array(attribute) }) const batch = z.object({ resourceLogs: z.array(z.object({ scopeLogs: z.array(z.object({ logRecords: z.array(record) })) })) }) const schema = z.object({ captures: z.array(batch), reference: z.record(z.string(), z.union([z.string(), z.number()])) }) @@ -32,7 +31,7 @@ it('adds real system metadata to the existing event through Loader and OTLP', as expect(fields[key]).toEqual(typeof value === 'number' ? { intValue: value } : { stringValue: value }) } if (platform() === 'win32' || platform() === 'darwin') { - expect(fields.missing_fields).toEqual({ kvlistValue: { values: [] } }) + expect(fields).not.toHaveProperty('missing_fields') expect(fields.cpu_model?.stringValue).toBeTruthy() } if (platform() === 'win32') expect(fields.os_version_full?.stringValue).toMatch(/^\d+\.\d+\.\d+\.\d+$/) diff --git a/packages/client/product-analytics/tests/environment-query.host.spec.ts b/packages/client/product-analytics/tests/environment-query.host.spec.ts index 6b84b4ee76..a359a7c598 100644 --- a/packages/client/product-analytics/tests/environment-query.host.spec.ts +++ b/packages/client/product-analytics/tests/environment-query.host.spec.ts @@ -1,5 +1,12 @@ -import { expect, it } from 'vitest' +import { expect, it, vi } from 'vitest' +import * as childProcess from 'node:child_process' import { queryEnvironment } from '../src/environment-query.ts' +import { collectEnvironment } from '../src/environment.ts' + +vi.mock('node:child_process', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, execFile: vi.fn(actual.execFile) } +}) const options = () => ({ timeoutMillis: 5000, maxOutputBytes: 16384, signal: new AbortController().signal }) @@ -40,3 +47,24 @@ it('aborts an in-flight query and waits for child closure', async () => { lifetime.abort() expect(await result).toEqual({ kind: 'missing', reason: 'query-failed' }) }) + + +it('isolates synchronous spawn failures without rejecting or creating a child', async () => { + const spawn = vi.mocked(childProcess.execFile) + spawn.mockImplementationOnce(() => { + throw Object.assign(new Error('private spawn failure'), { code: 'ENOMEM' }) + }) + try { + expect(await queryEnvironment(process.execPath, [], options())).toEqual({ kind: 'missing', reason: 'query-failed' }) + } finally { spawn.mockClear() } +}) + + +it.runIf(process.platform === 'darwin')('matches the real macOS kernel architecture, including Intel without the arm64 OID', async () => { + const reference = await queryEnvironment('/usr/sbin/sysctl', ['-i', '-n', 'hw.optional.arm64'], options()) + expect(reference.kind).toBe('value') + if (reference.kind !== 'value') throw new Error('macOS architecture reference unavailable') + const snapshot = await collectEnvironment(options()) + expect(snapshot.os_arch).toBe(reference.stdout.trim() === '1' ? 'arm64' : 'x64') + expect(snapshot.missing_fields).not.toHaveProperty('os_arch') +}) diff --git a/packages/client/product-analytics/tests/environment.host.spec.ts b/packages/client/product-analytics/tests/environment.host.spec.ts index 6fba26822d..049595acb4 100644 --- a/packages/client/product-analytics/tests/environment.host.spec.ts +++ b/packages/client/product-analytics/tests/environment.host.spec.ts @@ -121,11 +121,11 @@ it('rejects malformed JSON without exposing raw output', async () => { expect(JSON.stringify(result)).not.toContain('private') }) -it.each(['1', '0'])('reports macOS product version and native architecture when arm64=%s', async (arm) => { +it.each(['1', undefined, '0'])('reports macOS product version and native architecture when arm64=%s', async (arm) => { vi.mocked(os.platform).mockReturnValue('darwin') vi.mocked(os.release).mockReturnValue('26.0.0') vi.mocked(queryEnvironment).mockResolvedValueOnce({ kind: 'value', stdout: macVersion }) - .mockResolvedValueOnce({ kind: 'value', stdout: macCpu.replace('arm64: 1', `arm64: ${arm}`) }) + .mockResolvedValueOnce({ kind: 'value', stdout: arm === undefined ? 'hw.physicalcpu: 8\n' : macCpu.replace('arm64: 1', `arm64: ${arm}`) }) const result = await collectEnvironment(options()) expect(result).toMatchObject({ os_name: 'macos', os_version: 'darwin 26.0.0', os_version_full: '27.0.1', os_build: '26A434', os_arch: arm === '1' ? 'arm64' : 'x64', app_arch: process.arch, cpu_physical_core_count: 8, missing_fields: {} }) @@ -147,7 +147,7 @@ it.each(['', 'ProductVersion: garbage\nBuildVersion: private\nProductVersionExtr vi.mocked(queryEnvironment).mockResolvedValueOnce({ kind: 'value', stdout }) .mockResolvedValueOnce({ kind: 'value', stdout: 'hw.physicalcpu: 0\nhw.optional.arm64: unknown' }) expect((await collectEnvironment(options())).missing_fields).toEqual({ - os_version_full: 'invalid-value', os_build: 'invalid-value', cpu_physical_core_count: 'invalid-value', os_arch: 'unavailable', + os_version_full: 'invalid-value', os_build: 'invalid-value', cpu_physical_core_count: 'invalid-value', os_arch: 'invalid-value', }) }) @@ -174,6 +174,21 @@ it('isolates refused synchronous OS sources', async () => { it('marks unsupported platform queries without spawning commands', async () => { vi.mocked(os.platform).mockReturnValue('linux') - expect((await collectEnvironment(options())).missing_fields).toEqual({ os_version_full: 'unsupported', os_build: 'unsupported', cpu_physical_core_count: 'unsupported' }) + expect((await collectEnvironment(options())).missing_fields).toEqual({ os_version_full: 'unsupported', os_build: 'unsupported', os_arch: 'unsupported', cpu_physical_core_count: 'unsupported' }) expect(queryEnvironment).not.toHaveBeenCalled() }) + + +it.each(['', 'ProductVersionExtra: (a)\n', 'ProductVersionExtra:\n'])('accepts an inline macOS response suffix without doubling it: %s', async (extra) => { + vi.mocked(os.platform).mockReturnValue('darwin') + vi.mocked(queryEnvironment).mockResolvedValueOnce({ kind: 'value', stdout: macVersion.replace('27.0.1', '13.4.1 (a)') + extra }) + .mockResolvedValueOnce({ kind: 'value', stdout: macCpu }) + expect((await collectEnvironment(options())).os_version_full).toBe('13.4.1 (a)') +}) + +it('rejects conflicting macOS response suffixes', async () => { + vi.mocked(os.platform).mockReturnValue('darwin') + vi.mocked(queryEnvironment).mockResolvedValueOnce({ kind: 'value', stdout: macVersion.replace('27.0.1', '13.4.1 (a)') + 'ProductVersionExtra: (b)\n' }) + .mockResolvedValueOnce({ kind: 'value', stdout: macCpu }) + expect((await collectEnvironment(options())).missing_fields.os_version_full).toBe('invalid-value') +}) diff --git a/packages/client/product-analytics/tests/fixtures/environment.host.ts b/packages/client/product-analytics/tests/fixtures/environment.host.ts index a19cd60755..8dc08b543b 100644 --- a/packages/client/product-analytics/tests/fixtures/environment.host.ts +++ b/packages/client/product-analytics/tests/fixtures/environment.host.ts @@ -34,10 +34,14 @@ try { await analytics.report({ eventName: 'desktop_app_launch', timestamp: 1_800_000_000_000, attributes: {} }) } finally { await ctx.fiber.dispose() } if (platform() === 'darwin') { - reference.os_version_full = (await run('/usr/bin/sw_vers', ['-productVersion'])).stdout.trim() + const productVersion = (await run('/usr/bin/sw_vers', ['-productVersion'])).stdout.trim() + const { stdout: productInfo } = await run('/usr/bin/sw_vers', []) + const extra = productInfo.match(/^ProductVersionExtra:\s*(\([a-z]\))/m)?.[1] + reference.os_version_full = extra === undefined || productVersion.endsWith(extra) + ? productVersion : `${productVersion} ${extra}` reference.os_build = (await run('/usr/bin/sw_vers', ['-buildVersion'])).stdout.trim() reference.cpu_physical_core_count = Number((await run('/usr/sbin/sysctl', ['-n', 'hw.physicalcpu'])).stdout.trim()) - reference.os_arch = (await run('/usr/sbin/sysctl', ['-n', 'hw.optional.arm64'])).stdout.trim() === '1' ? 'arm64' : 'x64' + reference.os_arch = (await run('/usr/sbin/sysctl', ['-i', '-n', 'hw.optional.arm64'])).stdout.trim() === '1' ? 'arm64' : 'x64' } else if (platform() === 'win32') { const systemRoot = process.env.SystemRoot if (systemRoot === undefined) throw new Error('Windows fixture requires SystemRoot') diff --git a/tsconfig.client.json b/tsconfig.client.json index 5d7035ed6f..133b3a35f6 100644 --- a/tsconfig.client.json +++ b/tsconfig.client.json @@ -44,8 +44,8 @@ "scripts/client-bundle-css.spec.ts", "scripts/client-bundle-purity.spec.ts" ], - // A `*.host.spec.ts` covers the Host half of a split client package and - // belongs to the host aggregate, which excludes this program's `*.client.*` + // Host unit tests (`*.host.spec.ts`), e2e tests (`*.host.e2e.ts`), and fixtures + // (`*.host.ts`) belong to the host aggregate, which excludes `*.client.*` // in turn. `exclude` wins over `include`, so the test glob above stays broad. "exclude": [ "packages/client/*/tests/**/*.host.spec.ts",