Merge pull request #6197 from deepseek-harness/07akioni/desktop-environment-telemetry

feat(telemetry): attach cached desktop environment to product events
This commit is contained in:
07akioni authored and GitHub committed 2026-10-14 21:29:33 +08:00
commit 7ba5e3a01f
23 files changed
+830 -39

No files matched your search

+5 -1
View File
@@ -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<boolean>
/** 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
}
```
<!-- END GENERATED config-catalog:@deepseek-ai/dsh-client-product-analytics -->
+5 -1
View File
@@ -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<boolean>
/** 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
}
```
<!-- END GENERATED config-catalog:@deepseek-ai/dsh-client-product-analytics -->
+2 -2
View File
@@ -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
+4 -2
View File
@@ -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.
@@ -51,7 +51,9 @@ Authenticated event intake; disabled instances do not inspect identity or accept
@Remote({ mode: 'stream' }) async *watchPolicy(signal: AbortSignal): AsyncIterable<boolean>
/**
* 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.
*/
+4 -2
View File
@@ -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。记录入队时填写观测时间。
@@ -51,7 +51,9 @@ Authenticated event intake; disabled instances do not inspect identity or accept
@Remote({ mode: 'stream' }) async *watchPolicy(signal: AbortSignal): AsyncIterable<boolean>
/**
* 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.
*/
@@ -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() })
@@ -15,11 +15,11 @@
en: 2fee9973f19f9401
zh: 8ab4b55413cb8408
/deepseek-ai-dsh-client-product-analytics/use-this-package:
en: 3932aceab765ab96
zh: 8b5174fff1208053
en: 042e8eef8647b025
zh: 256ff24fee965eda
/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
@@ -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
+22 -4
View File
@@ -21,11 +21,27 @@ Desktop reports selected interactions through the existing OTel product exporter
<a id="use-this-package"></a>
## 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`; other platforms retain the raw Node platform name (such as `linux`) |
| `os_version` | Legacy `<Node platform> <kernel release>` 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` (`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` | 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, 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.
@@ -39,7 +55,7 @@ Only `plugin_toggle` carries `plugin_type`: plugin rows use `plugin`, and packag
<details>
<summary>Implementation internals — click to expand</summary>
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.
</details>
@@ -58,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.
+22 -4
View File
@@ -21,11 +21,27 @@ kind: "package-reference"
<a id="use-this-package"></a>
## 使用本包
桌面端同时装配 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 平台名称(如 `linux`) |
| `os_version` | 原有的 `<Node 平台> <内核版本>`,来自 `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`(`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` | 按字段名排序、逗号分隔的 `field=reason` 项,原因使用 `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;埋点与导出
<details>
<summary>实现细节——点击展开</summary>
展示组件接收回调。共享 Client 服务读取可选的桌面埋点发送器,后者要求同时存在原生 preload 标识和 同步的 Host 采集策略。发送器在被其他插件调用时保留自身的 RPC 上下文。原生操作使用已认证的 Host API,其生成的 Typert 校验器只接受类型中声明的事件字段。Host 在入队前补充身份信息,并监听实时压缩事件,不重放会话历史。
展示组件接收回调。共享 Client 服务读取可选的桌面埋点发送器,后者要求同时存在原生 preload 标识和 同步的 Host 采集策略。发送器在被其他插件调用时保留自身的 RPC 上下文。原生操作使用已认证的 Host API,其生成的 Typert 校验器只接受类型中声明的事件字段。Host 在入队前附加缓存的静态环境和当前身份信息,并监听实时压缩事件,不重放会话历史。
</details>
@@ -58,6 +74,8 @@ Electron 将构建内联的 `DSH_CLIENT_VERSION` 传给 Host;埋点与导出
交付采用尽力而为的方式。
- 环境仅采样一次:启动期间的资源争用可能导致超时,并缓存到本次运行结束。`missing_fields` 表示采样失败,不表示硬件缺失;后续事件不重试。部署可提高查询期限;重新挂载或重启会再次采样。
- 主机安全策略可能阻止或终止固定的 PowerShell 查询,使受管理的 Windows 机器产生 `query-failed`。
- 关闭只停止新采集,不清空导出器队列。
- 渲染进程的发送失败会被丢弃,导出器没有持久化待发队列或数据仓库确认。
- 原生启动事件等待 Host 认证完成;若进程在 Host 就绪前失败,则无法上报启动事件。
@@ -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",
@@ -0,0 +1,48 @@
/** Bounded, cancellable queries of fixed operating-system metadata commands. */
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'
/** 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<QueryResult> {
const result = Promise.withResolvers<QueryResult>()
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<void>((resolve) => { child.once('close', () => { resolve() }) })
const [output] = await Promise.all([result.promise, closed])
return output
}
@@ -0,0 +1,174 @@
/** Static Desktop environment fields, independent of account and credential availability. */
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'
/** 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: Partial<Record<EnvironmentField, MissingReason>>
}
type EnvironmentField = Exclude<keyof EnvironmentAttributes, 'missing_fields' | 'os_name' | 'app_arch'>
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<EnvironmentAttributes> {
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 = <K extends EnvironmentField>(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<typeof cpus> = []
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<QueryResult> => 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<number, string> = { 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') || undefined
const match = product?.match(/^(\d+(?:\.\d+){1,2})(?: (\([a-z]\)))?$/)
if (product !== undefined && match !== undefined && match !== null && (extra === undefined || /^\([a-z]\)$/.test(extra))
&& (match[2] === undefined || extra === undefined || match[2] === extra)) {
const suffix = match[2] ?? extra
fields.os_version_full = suffix === undefined ? product : `${match[1]} ${suffix}`
} 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 === undefined || arm === '0') fields.os_arch = 'x64'
else missing(['os_arch'], 'invalid-value')
}
} else {
missing(['os_version_full', 'os_build', 'os_arch', 'cpu_physical_core_count'], 'unsupported')
}
return fields
}
function lines(stdout: string): Map<string, string> {
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<T>(stdout: string, schema: z.ZodType<T>): 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
}
+34 -5
View File
@@ -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<boolean>
/** 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<EnvironmentAttributes> | 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,27 @@ export default class ProductAnalytics extends TypertRemoteService {
async report(event: ProductEvent): Promise<void> {
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
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,
...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 },
...identity === undefined ? {} : { os_version: identity.osVersion },
},
})
} catch (error) {
@@ -2,11 +2,21 @@ 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: {},
}
const { missing_fields: _missingFields, ...commonEnvironment } = environment
beforeEach(() => { vi.resetAllMocks(); vi.mocked(collectEnvironment).mockResolvedValue(environment) })
const cleanup: (() => Promise<void>)[] = []
afterEach(async () => { for (const dispose of cleanup.splice(0).reverse()) await dispose() })
@@ -27,6 +37,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,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: { button_name: 'sign_in', device_id: 'login-device', user_id: 'user-1', app_version: 'test-version', os_version: expect.any(String) as string },
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({ 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 () => {
@@ -79,11 +91,26 @@ 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()
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()
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(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')
})
@@ -159,3 +186,57 @@ 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<EnvironmentAttributes>()
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).toBe('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<EnvironmentAttributes>()
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<void>((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()
})
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')
})
@@ -0,0 +1,42 @@
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(),
}).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()])) })
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).not.toHaveProperty('missing_fields')
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)
@@ -0,0 +1,70 @@
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<typeof childProcess>()
return { ...actual, execFile: vi.fn(actual.execFile) }
})
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' })
})
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')
})
@@ -0,0 +1,194 @@
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<typeof os>(),
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', 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: 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: {} })
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: 'invalid-value',
})
})
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', 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')
})
@@ -0,0 +1,70 @@
/** 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<string, string | number> = { 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') {
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', ['-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')
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, string>)[Number(architecture)]!
}
await writeFile('captures.json', JSON.stringify({ captures, reference }))
} finally {
const closed = once(receiver, 'close')
receiver.close()
receiver.closeAllConnections()
await closed
}
@@ -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
@@ -7,7 +7,9 @@
},
"files": [
"src/index.ts",
"src/events.ts"
"src/events.ts",
"src/environment.ts",
"src/environment-query.ts"
],
"references": [
{
@@ -1809,7 +1809,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
},
{
signature: '@Remote async report(event: ProductEvent): Promise<void>',
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.',
},
+6
View File
@@ -3181,6 +3181,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
@@ -3196,6 +3199,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
+5 -3
View File
@@ -44,11 +44,13 @@
"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"
"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" },