feat(telemetry): add explicit product event OTLP exporter

This commit is contained in:
07akioni committed 2026-09-21 16:28:18 +08:00
1 parent 933d3bf39e
commit 910d52f717
36 files changed
+922 -14

No files matched your search

+1
View File
@@ -53,6 +53,7 @@ External packages installed for runtime use or distributed inside the prebuilt b
| [`@openai/codex`](https://github.com/openai/codex) | Apache-2.0 |
| [`@opentelemetry/api`](https://github.com/open-telemetry/opentelemetry-js) | Apache-2.0 |
| [`@opentelemetry/api-logs`](https://github.com/open-telemetry/opentelemetry-js) | Apache-2.0 |
| [`@opentelemetry/core`](https://github.com/open-telemetry/opentelemetry-js) | Apache-2.0 |
| [`@opentelemetry/exporter-logs-otlp-http`](https://github.com/open-telemetry/opentelemetry-js) | Apache-2.0 |
| [`@opentelemetry/otlp-exporter-base`](https://github.com/open-telemetry/opentelemetry-js) | Apache-2.0 |
| [`@opentelemetry/resources`](https://github.com/open-telemetry/opentelemetry-js) | Apache-2.0 |
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/capability-seams.md
capability-seams.md: bbd44bce011404ee476131b9f498618a8ef5c708
capability-seams.zh.md: 4e9f826bc27420ba372ac6feb688cc23bc58e141
capability-seams.md: f6482d487543a9b1b0d9251c1aa6089b7ca2c558
capability-seams.zh.md: f9a9d2d92b9d4253bfc00ed4ebbb9433a78138e7
+4
View File
@@ -7,6 +7,8 @@ A service can be a core spine service, a swappable capability seam, or a bundle/
```mermaid
flowchart LR
pkg_product_telemetry_otel["product-telemetry-otel"]
svc_productTelemetry["ctx.productTelemetry<br/>Product usage event sender"]
pkg_hmr["hmr"]
svc_hmr["ctx.hmr<br/>Serialized module and configuration reloads"]
pkg_app_boot["app-boot"]
@@ -337,6 +339,7 @@ flowchart LR
pkg_plan_mode --> svc_planMode
pkg_plugin_manager --> svc_pluginManager
pkg_plugin_package_inventory_deepseek --> svc_deepseekLlmApiExtensions
pkg_product_telemetry_otel --> svc_productTelemetry
pkg_ptc_runtime --> svc_ptcRuntime
pkg_ptc_runtime_node --> svc_ptcRuntime
pkg_pwsh_local --> svc_shell
@@ -551,6 +554,7 @@ flowchart LR
| ctx key | Role | Owner | Implementations | Direct consumers | Companion plugins | Note |
| --- | --- | --- | --- | --- | --- | --- |
| `ctx.productTelemetry` | `core` | [`product-telemetry-otel`](../packages/host/product-telemetry-otel) | - | - | - | Exports explicitly submitted analytics events through OTLP/HTTP; mounting alone collects nothing. |
| `ctx.hmr` | `core` | [`hmr`](../packages/boot/hmr) | - | [`app-boot`](../packages/boot/app-boot) | - | Owns module and exact configuration watchers; application mutations share its queue and automatic reloads await the application file lock. |
| `ctx.pluginManager` | `core` | [`plugin-manager`](../packages/boot/plugin-manager) | - | [`plugin-manager`](../packages/boot/plugin-manager), `ui-settings-plugin-inventory` | - | Shares profile package operations with the CLI and reports persisted and running state to Web and agent callers. |
| `ctx.profileContext` | `core` | [`app-boot`](../packages/boot/app-boot) | - | [`plugin-manager`](../packages/boot/plugin-manager) | - | The dsh launcher supplies data-only profile locations and composition inputs; reload scheduling belongs to dsh-hmr. |
+4
View File
@@ -9,6 +9,8 @@
```mermaid
flowchart LR
pkg_product_telemetry_otel["product-telemetry-otel"]
svc_productTelemetry["ctx.productTelemetry<br/>Product usage event sender"]
pkg_hmr["hmr"]
svc_hmr["ctx.hmr<br/>Serialized module and configuration reloads"]
pkg_app_boot["app-boot"]
@@ -339,6 +341,7 @@ flowchart LR
pkg_plan_mode --> svc_planMode
pkg_plugin_manager --> svc_pluginManager
pkg_plugin_package_inventory_deepseek --> svc_deepseekLlmApiExtensions
pkg_product_telemetry_otel --> svc_productTelemetry
pkg_ptc_runtime --> svc_ptcRuntime
pkg_ptc_runtime_node --> svc_ptcRuntime
pkg_pwsh_local --> svc_shell
@@ -553,6 +556,7 @@ flowchart LR
| ctx 键 | 角色 | 所属包 | 实现 | 直接消费方 | 配套插件 | 说明 |
| --- | --- | --- | --- | --- | --- | --- |
| `ctx.productTelemetry` | `core` | [`product-telemetry-otel`](../packages/host/product-telemetry-otel) | - | - | - | 通过 OTLP/HTTP 发送显式提交的分析事件;仅挂载插件不会采集信息。 |
| `ctx.hmr` | `core` | [`hmr`](../packages/boot/hmr) | - | [`app-boot`](../packages/boot/app-boot) | - | 负责模块和精确配置监听;应用修改共用其队列,自动重载等待应用文件锁。 |
| `ctx.pluginManager` | `core` | [`plugin-manager`](../packages/boot/plugin-manager) | - | [`plugin-manager`](../packages/boot/plugin-manager), `ui-settings-plugin-inventory` | - | 与 CLI 共享 profile 包操作,并向 Web 和 Agent 调用方分别报告持久状态与运行状态。 |
| `ctx.profileContext` | `core` | [`app-boot`](../packages/boot/app-boot) | - | [`plugin-manager`](../packages/boot/plugin-manager) | - | dsh launcher 提供纯数据形式的 profile 位置与组合输入;重载调度由 dsh-hmr 负责。 |
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/config-catalog.md
config-catalog.md: d3d1b4fe47bf6dc745dc9a00ac6ef317ab0d3ca8
config-catalog.zh.md: 3f204af30d5e973c03cd24c6c2873bb7756d334e
config-catalog.md: fe9edd406fa6305494e0e1f6fc71725278716685
config-catalog.zh.md: 0a45e6573a94e46fa8d615af8a1f6e459c9cb2d2
+34
View File
@@ -2064,6 +2064,40 @@ export interface Config {
Source: [`packages/llm/plugin-package-inventory-deepseek/src/index.ts:32`](../packages/llm/plugin-package-inventory-deepseek/src/index.ts)
<a id="deepseek-aidsh-product-telemetry-otel"></a>
## `@deepseek-ai/dsh-product-telemetry-otel`
```ts config-catalog
/** Collector routing, application identity, and bounded in-memory batch settings. */
export interface Config {
/** Full HTTP(S) logs URL. */
endpoint: string
/** Collector routing header. */
channel: string
/** Resource service.name supplied by the application composition. */
serviceName: string
/** Resource service.version supplied by the application composition. */
serviceVersion: string
/** Omit to honor OTEL_EXPORTER_OTLP_LOGS_COMPRESSION / OTEL_EXPORTER_OTLP_COMPRESSION. */
compression?: 'none' | 'gzip'
/** Maximum records per export; must not exceed maxQueueSize. */
maxExportBatchSize: number
/** Maximum queued records; the SDK drops new records when full. */
maxQueueSize: number
/** Delay before exporting a partial batch. */
scheduledDelayMillis: number
/** Exporter HTTP deadline, including SDK transient-error retries. */
timeoutMillis: number
/** Processor deadline for one batch export. */
exportTimeoutMillis: number
/** Outer shutdown wait; pending exports may be lost after this deadline. */
shutdownTimeoutMillis: number
}
```
Source: [`packages/host/product-telemetry-otel/src/index.ts:35`](../packages/host/product-telemetry-otel/src/index.ts)
<a id="deepseek-aidsh-ptc-runtime-node"></a>
## `@deepseek-ai/dsh-ptc-runtime-node`
+34
View File
@@ -2068,6 +2068,40 @@ export interface Config {
来源:[`packages/llm/plugin-package-inventory-deepseek/src/index.ts:32`](../packages/llm/plugin-package-inventory-deepseek/src/index.ts)
<a id="deepseek-aidsh-product-telemetry-otel"></a>
## `@deepseek-ai/dsh-product-telemetry-otel`
```ts config-catalog
/** Collector routing, application identity, and bounded in-memory batch settings. */
export interface Config {
/** Full HTTP(S) logs URL. */
endpoint: string
/** Collector routing header. */
channel: string
/** Resource service.name supplied by the application composition. */
serviceName: string
/** Resource service.version supplied by the application composition. */
serviceVersion: string
/** Omit to honor OTEL_EXPORTER_OTLP_LOGS_COMPRESSION / OTEL_EXPORTER_OTLP_COMPRESSION. */
compression?: 'none' | 'gzip'
/** Maximum records per export; must not exceed maxQueueSize. */
maxExportBatchSize: number
/** Maximum queued records; the SDK drops new records when full. */
maxQueueSize: number
/** Delay before exporting a partial batch. */
scheduledDelayMillis: number
/** Exporter HTTP deadline, including SDK transient-error retries. */
timeoutMillis: number
/** Processor deadline for one batch export. */
exportTimeoutMillis: number
/** Outer shutdown wait; pending exports may be lost after this deadline. */
shutdownTimeoutMillis: number
}
```
来源: [`packages/host/product-telemetry-otel/src/index.ts:35`](../packages/host/product-telemetry-otel/src/index.ts)
<a id="deepseek-aidsh-ptc-runtime-node"></a>
## `@deepseek-ai/dsh-ptc-runtime-node`
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/module-graph.md
module-graph.md: 8511f674c84bcfa11fb9e2b137b12025ec540c3e
module-graph.zh.md: 67ba8d7f6b2582200b8a2ecc2215f7b317880081
module-graph.md: 8976efc78f5c6f10b27c7c20cd4b380821ce3d61
module-graph.zh.md: d1f770ec1fdebfca93d7fb436d75bee8325633b2
+2
View File
@@ -274,6 +274,7 @@ flowchart TD
pkg_host_open_in_app["host-open-in-app"]
pkg_host_plugin_inventory["host-plugin-inventory"]
pkg_host_webserver["host-webserver"]
pkg_product_telemetry_otel["product-telemetry-otel"]
end
subgraph group_identity["packages/identity"]
pkg_anonymous_user_id["anonymous-user-id"]
@@ -1419,6 +1420,7 @@ flowchart TD
| [`host-directory-picker-native`](../packages/host/directory-picker-native) | `host` | — |
| [`host-open-in-app`](../packages/host/open-in-app) | `host` | — |
| [`host-webserver`](../packages/host/webserver) | `host` | — |
| [`product-telemetry-otel`](../packages/host/product-telemetry-otel) | `host` | — |
| [`invariants`](../packages/runtime-diagnostics/invariants) | `runtime-diagnostics` | — |
| [`session-format`](../packages/session/session-format) | `session` | — |
| [`session-format-v0-to-v1`](../packages/session/session-format-v0-to-v1) | `session` | — |
+2
View File
@@ -276,6 +276,7 @@ flowchart TD
pkg_host_open_in_app["host-open-in-app"]
pkg_host_plugin_inventory["host-plugin-inventory"]
pkg_host_webserver["host-webserver"]
pkg_product_telemetry_otel["product-telemetry-otel"]
end
subgraph group_identity["packages/identity"]
pkg_anonymous_user_id["anonymous-user-id"]
@@ -1421,6 +1422,7 @@ flowchart TD
| [`host-directory-picker-native`](../packages/host/directory-picker-native) | `host` | — |
| [`host-open-in-app`](../packages/host/open-in-app) | `host` | — |
| [`host-webserver`](../packages/host/webserver) | `host` | — |
| [`product-telemetry-otel`](../packages/host/product-telemetry-otel) | `host` | — |
| [`invariants`](../packages/runtime-diagnostics/invariants) | `runtime-diagnostics` | — |
| [`session-format`](../packages/session/session-format) | `session` | — |
| [`session-format-v0-to-v1`](../packages/session/session-format-v0-to-v1) | `session` | — |
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/subsystems/README.md
README.md: db21e0fe6bea366c4d7efc1d96d247dd255f54da
README.zh.md: 4b2b28dbf9a39da0c0ce3a062c4de3261501f7d9
README.md: 7534586b4bd2686cd75da160fe044806afcff539
README.zh.md: 1132d9a9e0c0bb945532020b314f87a08b84b933
+1
View File
@@ -6,6 +6,7 @@ One page per subsystem of the DeepSeek Harness: what it is, the data structures
| Page | Owns |
|---|---|
| [product-telemetry.md](product-telemetry.md) | Explicit product analytics submission and OTLP/HTTP transport |
| [boot.md](boot.md) | Current-profile plugin management and launcher reload coordination |
| [core.md](core.md) | how `packages/core` controls the agent loop: the package-by-package loop description, agent creation and ownership (`AgentHandle`), the `Agent` handle's delivery/cancellation/interception contracts, and the repo-wide type patterns (`…Map → derived-union`, branded ids) |
| [llm-streaming.md](llm-streaming.md) | the `packages/llm` conversation types — `Message`/`ContentBlock`, the assembled model request, the `StreamChunk` wire protocol and adapter contract, `BlockAssembler`, and the `LlmAdapter` provider contract |
+1
View File
@@ -6,6 +6,7 @@
| 页面 | 负责内容 |
|---|---|
| [product-telemetry.md](product-telemetry.zh.md) | 显式产品分析事件提交与 OTLP/HTTP 传输 |
| [boot.md](boot.zh.md) | 当前 profile 插件管理与 launcher 重载协调 |
| [core.md](core.zh.md) | `packages/core` 如何控制 agent loop(智能体循环):逐包的循环说明、agent 创建与所有权(`AgentHandle`)、`Agent` 句柄的投递/取消/拦截约定,以及全仓通用类型模式(`…Map → derived-union`、品牌化 id) |
| [llm-streaming.md](llm-streaming.zh.md) | `packages/llm` 的对话类型——`Message`/`ContentBlock`、组装完成的模型请求、`StreamChunk` wire protocol 和适配器约定(adapter contract)、`BlockAssembler`,以及 `LlmAdapter` 提供方约定 |
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# 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.md: b9abdfc3a781ce5dc6237c25f2f7e0b045776125
product-telemetry.zh.md: de132fec7c7a2e394bb3ee7ab6e8d745c0912b66
+35
View File
@@ -0,0 +1,35 @@
# Product telemetry
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. No Session data or identifiers are collected automatically.
`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.
Enqueue is synchronous and does not acknowledge delivery. The SDK owns batching and retry; local diagnostics report export failures. See the package README for configuration, shutdown, and loss limits.
<!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
<a id="cordis-surface"></a>
## Cordis API
Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — the language sides differ only in locale-specific paired document paths. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md).
<a id="ctxproducttelemetry--producttelemetry"></a>
### `ctx.productTelemetry` — `ProductTelemetry`
Host analytics sender. Mounting alone sends nothing; the owning fiber drains it on unload.
```ts cordis-catalog
/**
* Enqueue one selected product event without waiting for network delivery.
* Queue admission and shutdown completion are not collector or warehouse acknowledgements.
* @param record - caller-owned event containing only approved analytics fields.
*/
emit(record: ProductTelemetryRecord): void
```
Source: [`packages/host/product-telemetry-otel/src/index.ts`](../../packages/host/product-telemetry-otel/src/index.ts)
<!-- END GENERATED cordis-surface -->
+35
View File
@@ -0,0 +1,35 @@
# 产品埋点
[English](product-telemetry.md) | 中文
[产品埋点插件](../../packages/host/product-telemetry-otel/README.zh.md) 通过 OTLP/HTTP 发送明确选定的分析事件。`productTelemetry` 服务仅负责提交;产品消费方决定事件发生时机与获准采集的字段。不自动采集 Session 数据或标识。
`ProductTelemetryRecord` 要求事件名称、字符串 body 和 Unix 毫秒时间戳。属性接受字符串、数字、布尔标量,以及这些值组成的单层对象(`ProductTelemetryScalar`)。可选的严重程度使用 OTel 严重程度数字,默认为 INFO。记录入队时填写观测时间。
入队同步完成,不代表送达确认。SDK 负责批量发送与重试;发送失败会产生本地诊断。配置、退出和丢失限制见包 README。
<!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
<a id="cordis-surface"></a>
## Cordis API
Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — the language sides differ only in locale-specific paired document paths. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.zh.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md).
<a id="ctxproducttelemetry--producttelemetry"></a>
### `ctx.productTelemetry` — `ProductTelemetry`
Host analytics sender. Mounting alone sends nothing; the owning fiber drains it on unload.
```ts cordis-catalog
/**
* Enqueue one selected product event without waiting for network delivery.
* Queue admission and shutdown completion are not collector or warehouse acknowledgements.
* @param record - caller-owned event containing only approved analytics fields.
*/
emit(record: ProductTelemetryRecord): void
```
Source: [`packages/host/product-telemetry-otel/src/index.ts`](../../packages/host/product-telemetry-otel/src/index.ts)
<!-- END GENERATED cordis-surface -->
@@ -1596,6 +1596,18 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
},
],
},
{
key: 'productTelemetry',
summary: 'Host analytics sender.',
description: 'Host analytics sender. Mounting alone sends nothing; the owning fiber drains it on unload.',
methods: [
{
signature: 'emit(record: ProductTelemetryRecord): void',
description: 'Enqueue one selected product event without waiting for network delivery. Queue admission and shutdown completion are not collector or warehouse acknowledgements.',
parameters: [{ name: 'record', description: 'caller-owned event containing only approved analytics fields.' }],
},
],
},
{
key: 'profileContext',
summary: 'Current profile facts; scheduling and mutation belong to their callers.',
@@ -5634,6 +5646,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'PreToolDecision',
declaration: 'export type PreToolDecision = {\n kind: \'allow\';\n} | {\n kind: \'deny\';\n reason: string;\n info?: ToolErrorInfo;\n} | {\n kind: \'cancel\';\n} | {\n kind: \'ask\';\n reason?: string;\n};',
},
{
name: 'ProductTelemetryRecord',
declaration: 'export interface ProductTelemetryRecord {\n eventName: string;\n body: string;\n timestamp: number;\n severityNumber?: SeverityNumber;\n attributes?: Record<string, ProductTelemetryScalar | Record<string, ProductTelemetryScalar>>;\n}',
},
{
name: 'ProductTelemetryScalar',
declaration: 'export type ProductTelemetryScalar = string | number | boolean;',
},
{
name: 'ProfilePnpmInvocation',
declaration: 'export interface ProfilePnpmInvocation {\n readonly command: string;\n readonly args: readonly string[];\n readonly env: Readonly<Record<string, string>>;\n}',
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/host/README.md
README.md: 0b595eb7e63f33ed8e11ec0f39438f5d79b6ab33
README.zh.md: 6a7507b7d8d2b2d791952783235a80280c8c0408
README.md: 4de7a5fdd00be392d19a58f4bc424fce53c5e112
README.zh.md: 80b74e0647c810b900f671d7d1256ab0d46107a9
+5 -2
View File
@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
## Summary
The `host/` group provides the web GUI's plain HTTP server, the SPA dist server that serves the built Web shell, the workspace-directory picking seam with its native, browse, and adaptive composition packages, the open-in-app application probe and launch routes, and the read-only plugin inventory projection. All eight packages are product packages; the browser transport lives in [`client/`](../client/README.md), and the composed application is [`apps/cli`](../../apps/cli/README.md) booting the [`dsh-base` bundle](../bundle/base/cordis.patch.yml) that serves the web app under `apps/web/`. The picker backends replace one another behind the shared seam.
The `host/` group provides the web GUI's plain HTTP server, the SPA dist server that serves the built Web shell, the workspace-directory picking seam with its native, browse, and adaptive composition packages, the open-in-app application probe and launch routes, and the read-only plugin inventory projection. These packages are product packages; the browser transport lives in [`client/`](../client/README.md), and the composed application is [`apps/cli`](../../apps/cli/README.md) booting the [`dsh-base` bundle](../bundle/base/cordis.patch.yml) that serves the web app under `apps/web/`. The picker backends replace one another behind the shared seam.
## Table of Contents
@@ -22,7 +22,7 @@ The `host/` group provides the web GUI's plain HTTP server, the SPA dist server
<a id="packages"></a>
## Packages
Eight packages play the host roles; each package README owns its contract and configuration.
The packages play the host roles; each package README owns its contract and configuration.
| Package | Role | ctx key |
|---|---|---|
@@ -34,6 +34,7 @@ Eight packages play the host roles; each package README owns its contract and co
| [`directory-picker-auto/`](directory-picker-auto/README.md) | Host-adaptive chooser that mounts the matching backend at boot | mounts a backend |
| [`open-in-app/`](open-in-app/README.md) | Application probe, icon, and launch routes opening the workspace directory in an installed application | consumes `ctx.webServer` |
| [`plugin-inventory/`](plugin-inventory/README.md) | Read-only projection of current Loader entries | Remote `pluginInventory/list` |
| [`product-telemetry-otel/`](product-telemetry-otel/README.md) | Explicit product usage events over OTLP/HTTP | `ctx.productTelemetry` |
-----
@@ -46,6 +47,8 @@ Start with the subsystem references for the transport and the workspace records,
- [Workspace subsystem](../../docs/subsystems/workspace.md) — the workspace records the directory picker feeds.
- [Web config-tree boot and transport layering](../../.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md) — ownership of the Web transport layers.
- [Product telemetry](../../docs/subsystems/product-telemetry.md) — explicit analytics event submission.
<a id="dev-note"></a>
## Dev Note
+5 -2
View File
@@ -9,7 +9,7 @@ kind: "package-group"
## 概述
`host/` 组提供 Web GUI 的普通 HTTP 服务器、服务已构建 Web 壳的 SPA dist 服务器、带原生/浏览/自适应组合包的工作区目录选择 seam、open-in-app 的应用探测与启动路由,以及只读的插件清单投影。这八个包都是产品包;浏览器传输位于 [`client/`](../client/README.zh.md),组合应用是 [`apps/cli`](../../apps/cli/README.zh.md),它启动 [`dsh-base` 组合包](../bundle/base/cordis.patch.yml) 来提供 `apps/web/` 下的 Web 应用。选择器后端可在共享 seam 后互相替换。
`host/` 组提供 Web GUI 的普通 HTTP 服务器、服务已构建 Web 壳的 SPA dist 服务器、带原生/浏览/自适应组合包的工作区目录选择 seam、open-in-app 的应用探测与启动路由,以及只读的插件清单投影。这些包都是产品包;浏览器传输位于 [`client/`](../client/README.zh.md),组合应用是 [`apps/cli`](../../apps/cli/README.zh.md),它启动 [`dsh-base` 组合包](../bundle/base/cordis.patch.yml) 来提供 `apps/web/` 下的 Web 应用。选择器后端可在共享 seam 后互相替换。
## 目录
@@ -22,7 +22,7 @@ kind: "package-group"
<a id="packages"></a>
## 包
八个包分别承担 Host 角色;各包的 README 拥有自己的约定与配置。
各包分别承担 Host 角色;各包的 README 拥有自己的约定与配置。
| 包 | 职责 | ctx 键 |
|---|---|---|
@@ -34,6 +34,7 @@ kind: "package-group"
| [`directory-picker-auto/`](directory-picker-auto/README.zh.md) | 在启动时挂载匹配后端的宿主自适应选择器 | 挂载一个后端 |
| [`open-in-app/`](open-in-app/README.zh.md) | 在已安装应用中打开 workspace 目录的应用探测、图标与启动路由 | 消费 `ctx.webServer` |
| [`plugin-inventory/`](plugin-inventory/README.zh.md) | 当前 Loader 条目的只读投影 | Remote `pluginInventory/list` |
| [`product-telemetry-otel/`](product-telemetry-otel/README.zh.md) | 通过 OTLP/HTTP 显式上报产品使用事件 | `ctx.productTelemetry` |
-----
@@ -46,6 +47,8 @@ kind: "package-group"
- [工作区子系统](../../docs/subsystems/workspace.zh.md)——目录选择器所喂给的工作区记录。
- [Web 配置树启动与传输分层](../../.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md)——Web 传输各层的所有权。
- [产品埋点](../../docs/subsystems/product-telemetry.zh.md)——显式提交分析事件。
<a id="dev-note"></a>
## 开发备注
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/host/product-telemetry-otel/README.md
README.md: 4ce0a8744b46391b8dbe3d7d6739f0e0e1d118c5
README.zh.md: 34075efae80c2e5275fbb782c485bea1b2661745
@@ -0,0 +1,104 @@
---
description: "Configure explicit product usage events, OTLP/HTTP routing, batching, and shutdown limits."
kind: "package-reference"
---
# @deepseek-ai/dsh-product-telemetry-otel
English | [中文](README.zh.md)
## Summary
Send selected product usage events to an OTLP/HTTP collector. Events carry a name, string summary, occurrence time, and scalar or one-level object attributes. Mounting the plugin collects nothing automatically; applications explicitly submit each event. Delivery is best effort and does not confirm warehouse ingestion.
## Table of Contents
- [Use this package](#use-this-package)
- [Understand the implementation](#understand-the-implementation)
- [Further Exploration](#further-exploration)
- [Model Experience](#model-experience)
- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work)
- [Dev Note](#dev-note)
-----
<a id="use-this-package"></a>
## Use this package
Mount the plugin in a Cordis composition with the application identity; override the collector endpoint when needed. The shipped profiles do not mount it.
```yaml
- name: '@deepseek-ai/dsh-product-telemetry-otel'
config:
endpoint: https://dsh-otel-collector.deepseeksvc.com/v1/logs
serviceName: dsh-telemetry-test
serviceVersion: test
compression: gzip
scheduledDelayMillis: 30000
```
| Field | Default | Meaning |
|---|---|---|
| `endpoint` | `https://dsh-otel-collector.deepseeksvc.com/v1/logs` | Full HTTP(S) logs URL |
| `serviceName`, `serviceVersion` | required | Application identity on the OTel resource |
| `channel` | `dsh_otel_report` | Collector `x-channel` header |
| `compression` | SDK environment | `gzip` or `none`; omission honors OTel compression environment variables |
| `maxExportBatchSize`, `maxQueueSize` | `512`, `2048` | Record-count limits; batch size cannot exceed queue size |
| `scheduledDelayMillis` | `30000` | Partial-batch export interval |
| `timeoutMillis` | `15000` | Exporter HTTP and retry deadline |
| `exportTimeoutMillis` | `20000` | Processor batch export deadline |
| `shutdownTimeoutMillis` | `21000` | Outer wait for shutdown; expiry reports possible loss |
Consumers inject `productTelemetry` and call `emit()` with explicitly selected analytics fields. Event names and field semantics belong to their product and analytics owners. The plugin reads no Session, account, credential, or device identifier. Callers must exclude prompts, responses, file contents, credentials, and other unapproved values.
The collector expects a string body and attributes containing strings, numbers, booleans, or objects of those scalars. Callers supply occurrence time in milliseconds; the plugin assigns observation time and defaults severity to INFO. Invalid transport configuration fails at activation. Export failures produce local warnings without making event submission wait for the network.
-----
<a id="understand-the-implementation"></a>
## Understand the implementation
<details>
<summary>Implementation internals — click to expand</summary>
A private OTel logger feeds `BatchLogRecordProcessor` and the HTTP JSON exporter. The SDK owns queueing, transient-error retries, and compression; plugin disposal drains pending records with a bounded wait. Export completion is observed separately because SDK shutdown can resolve after a rejected export. No global OTel provider is installed.
[`src/index.ts`](src/index.ts) owns configuration and submission. No runtime invariant companion is published: delivery has no independent local acknowledgement to compare with the SDK's queue.
</details>
-----
<a id="further-exploration"></a>
## Further Exploration
- [Product telemetry](../../../docs/subsystems/product-telemetry.md) — consumer types and service reference.
- [Session telemetry](../../session/session-telemetry-otel/README.md) — separate feedback-authorized Session reporting.
- [Testing policy](../../../docs/testing.md) — Loader composition and network fixtures.
-----
<a id="model-experience"></a>
## Model Experience
None, as the plugin exports explicit analytics records without contributing model context.
#### KV Cache effect
None; event submission does not change model requests.
## Known Limitations and Deferred Work
<a id="known-limitations-and-deferred-work"></a>
Delivery and collection remain limited to the following capabilities.
- No business events, renderer transport, or account/device identity are wired automatically.
- The queue is memory-only; overflow, network failure, and process exit can lose events. There is no durable outbox or warehouse acknowledgement.
- The SDK batches by record count, not encoded bytes. Callers must keep records within the collector's 4 MB limit and choose batch sizes appropriate to the receiver.
- Caller-selected strings are not redacted automatically. This package does not decide product disclosure or consent policy.
<a id="dev-note"></a>
### Dev Note
None.
@@ -0,0 +1,104 @@
---
description: "配置显式产品使用事件、OTLP/HTTP 路由、批量发送与退出等待上限。"
kind: "package-reference"
---
# @deepseek-ai/dsh-product-telemetry-otel
[English](README.md) | 中文
## 概述
将选定的产品使用事件发送到 OTLP/HTTP 接收服务。事件包含名称、字符串摘要、发生时间,以及标量或单层对象属性。挂载插件不会自动采集信息;应用需要显式提交每个事件。发送采用尽力而为方式,不代表数据已入仓。
## 目录
- [使用本包](#use-this-package)
- [理解实现](#understand-the-implementation)
- [延伸阅读](#further-exploration)
- [模型体验](#model-experience)
- [已知限制与待办工作](#known-limitations-and-deferred-work)
- [开发备注](#dev-note)
-----
<a id="use-this-package"></a>
## 使用本包
在 Cordis 组合中挂载插件并提供应用标识;需要时可覆盖接收地址。内置 profile 不挂载本插件。
```yaml
- name: '@deepseek-ai/dsh-product-telemetry-otel'
config:
endpoint: https://dsh-otel-collector.deepseeksvc.com/v1/logs
serviceName: dsh-telemetry-test
serviceVersion: test
compression: gzip
scheduledDelayMillis: 30000
```
| 字段 | 默认值 | 含义 |
|---|---|---|
| `endpoint` | `https://dsh-otel-collector.deepseeksvc.com/v1/logs` | 完整 HTTP(S) 日志地址 |
| `serviceName`, `serviceVersion` | 必填 | OTel resource 中的应用标识 |
| `channel` | `dsh_otel_report` | 接收服务的 `x-channel` 请求头 |
| `compression` | SDK 环境变量 | `gzip` 或 `none`;省略时遵循 OTel 压缩环境变量 |
| `maxExportBatchSize`, `maxQueueSize` | `512`, `2048` | 记录条数上限;批次大小不能超过队列大小 |
| `scheduledDelayMillis` | `30000` | 不满批次时的发送间隔 |
| `timeoutMillis` | `15000` | Exporter HTTP 发送与重试的超时时间 |
| `exportTimeoutMillis` | `20000` | Processor 批次导出的超时时间 |
| `shutdownTimeoutMillis` | `21000` | 退出时的等待上限;超时会提示可能丢失数据 |
消费方注入 `productTelemetry`,调用 `emit()` 提交明确选定的分析字段。事件名称与字段含义由产品和数据分析负责人定义。插件不读取 Session、账号、凭证或设备标识。调用方必须排除提示词、回答、文件内容、凭证及其他未经批准的数据。
接收服务要求 body 为字符串,attributes 的值为字符串、数字、布尔值或由这些标量组成的对象。调用方以毫秒提供发生时间;插件填写观测时间,默认严重程度为 INFO。无效传输配置在激活时报错。发送失败产生本地警告,事件提交不等待网络。
-----
<a id="understand-the-implementation"></a>
## 理解实现
<details>
<summary>实现细节——点击展开</summary>
私有 OTel logger 将记录交给 `BatchLogRecordProcessor` 和 HTTP JSON exporter。SDK 负责队列、临时错误重试与压缩;插件卸载时在限定时间内发送剩余记录。发送完成结果单独观测,因为 SDK 在发送被拒绝后仍可能正常完成退出。不安装全局 OTel provider。
[`src/index.ts`](src/index.ts) 负责配置与提交。不发布运行时不变量伴随模块:本地没有独立的送达确认可与 SDK 队列比较。
</details>
-----
<a id="further-exploration"></a>
## 延伸阅读
- [产品埋点](../../../docs/subsystems/product-telemetry.zh.md)——消费方类型与服务参考。
- [会话上报](../../session/session-telemetry-otel/README.zh.md)——独立的、由反馈授权的 Session 上报。
- [测试策略](../../../docs/testing.zh.md)——Loader 组合与网络测试夹具。
-----
<a id="model-experience"></a>
## 模型体验
无,因为插件仅发送显式分析记录,不提供模型上下文。
#### KV Cache 影响
无;提交事件不会改变模型请求。
## 已知限制与待办工作
<a id="known-limitations-and-deferred-work"></a>
发送和采集受以下能力限制。
- 不自动接入业务事件、渲染进程传输或账号与设备标识。
- 队列仅存在于内存;队列溢出、网络故障和进程退出可能丢失事件。没有持久发件箱或入仓确认。
- SDK 按记录条数而非编码字节数分批。调用方必须确保记录不超过接收服务的 4 MB 限制,并选择接收服务适用的批次大小。
- 不自动脱敏调用方选择的字符串。本包不决定产品告知或同意策略。
<a id="dev-note"></a>
### 开发备注
无。
@@ -0,0 +1,47 @@
{
"name": "@deepseek-ai/dsh-product-telemetry-otel",
"description": "Explicit product usage events exported through OpenTelemetry HTTP logs",
"version": "0.1.6-alpha.2",
"publishConfig": {
"access": "public"
},
"repository": {
"type": "git",
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
"directory": "packages/host/product-telemetry-otel"
},
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/types/**/*.d.ts"
],
"license": "MIT",
"dependencies": {
"@opentelemetry/api-logs": "^0.220.0",
"@opentelemetry/exporter-logs-otlp-http": "^0.220.0",
"@opentelemetry/otlp-exporter-base": "^0.220.0",
"@opentelemetry/resources": "^2.9.0",
"@opentelemetry/sdk-logs": "^0.220.0",
"@deepseek-ai/schemastery": "workspace:^",
"@opentelemetry/core": "2.9.0"
},
"peerDependencies": {
"@deepseek-ai/cordis": "workspace:^"
},
"devDependencies": {
"@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/cordis-plugin-loader": "workspace:^",
"@deepseek-ai/dsh-app-boot": "workspace:^",
"@deepseek-ai/dsh-loader-smoke": "workspace:^"
}
}
@@ -0,0 +1,154 @@
/** Explicit product usage events over OTLP/HTTP; no automatic collection or Session access. */
import { Context, Service } from '@deepseek-ai/cordis'
import z from '@deepseek-ai/schemastery'
import { SeverityNumber, type Logger } from '@opentelemetry/api-logs'
import { OTLPLogExporter } from '@opentelemetry/exporter-logs-otlp-http'
import { CompressionAlgorithm } from '@opentelemetry/otlp-exporter-base'
import { ExportResultCode } from '@opentelemetry/core'
import { resourceFromAttributes } from '@opentelemetry/resources'
import { BatchLogRecordProcessor, LoggerProvider } from '@opentelemetry/sdk-logs'
declare module '@deepseek-ai/cordis' {
interface Context {
productTelemetry: ProductTelemetry
}
}
/** Scalar values accepted by the collector's Arrow attributes map. */
export type ProductTelemetryScalar = string | number | boolean
/** Explicitly selected analytics fields; object values may contain scalars only. */
export interface ProductTelemetryRecord {
/** Product/DA-owned event name. */
eventName: string
/** Human-readable summary; never a prompt, response, credential, or file contents. */
body: string
/** Event occurrence time in Unix milliseconds. Observation time is assigned on enqueue. */
timestamp: number
/** OTel severity; omitted values use INFO. */
severityNumber?: SeverityNumber
/** Business fields selected by the caller; no automatic device or account identity. */
attributes?: Record<string, ProductTelemetryScalar | Record<string, ProductTelemetryScalar>>
}
/** Collector routing, application identity, and bounded in-memory batch settings. */
export interface Config {
/** Full HTTP(S) logs URL. */
endpoint: string
/** Collector routing header. */
channel: string
/** Resource service.name supplied by the application composition. */
serviceName: string
/** Resource service.version supplied by the application composition. */
serviceVersion: string
/** Omit to honor OTEL_EXPORTER_OTLP_LOGS_COMPRESSION / OTEL_EXPORTER_OTLP_COMPRESSION. */
compression?: 'none' | 'gzip'
/** Maximum records per export; must not exceed maxQueueSize. */
maxExportBatchSize: number
/** Maximum queued records; the SDK drops new records when full. */
maxQueueSize: number
/** Delay before exporting a partial batch. */
scheduledDelayMillis: number
/** Exporter HTTP deadline, including SDK transient-error retries. */
timeoutMillis: number
/** Processor deadline for one batch export. */
exportTimeoutMillis: number
/** Outer shutdown wait; pending exports may be lost after this deadline. */
shutdownTimeoutMillis: number
}
const positiveInteger = () => z.number().step(1).min(1).max(2_147_483_647)
/** Loader validation; the same schema resolves defaults for direct construction. */
export const Config: z<Partial<Config>, Config> = z.object({
endpoint: z.string().default('https://dsh-otel-collector.deepseeksvc.com/v1/logs'),
channel: z.string().default('dsh_otel_report'),
serviceName: z.string().required(),
serviceVersion: z.string().required(),
compression: z.union(['none', 'gzip']),
maxExportBatchSize: positiveInteger().default(512),
maxQueueSize: positiveInteger().default(2048),
scheduledDelayMillis: positiveInteger().default(30000),
timeoutMillis: positiveInteger().default(15000),
exportTimeoutMillis: positiveInteger().default(20000),
shutdownTimeoutMillis: positiveInteger().default(21000),
})
/** Host analytics sender. Mounting alone sends nothing; the owning fiber drains it on unload. */
export default class ProductTelemetry extends Service {
static Config = Config
private readonly logger: Logger
constructor(ctx: Context, input: Config) {
const config = Config(input)
const endpoint = new URL(config.endpoint)
if (!['http:', 'https:'].includes(endpoint.protocol)) {
throw new Error('product-telemetry-otel: endpoint must use HTTP or HTTPS')
}
if (config.maxExportBatchSize > config.maxQueueSize) {
throw new Error('product-telemetry-otel: maxExportBatchSize must not exceed maxQueueSize')
}
super(ctx, 'productTelemetry')
const exporter = new OTLPLogExporter({
url: config.endpoint,
headers: { 'x-channel': config.channel },
timeoutMillis: config.timeoutMillis,
...(config.compression === undefined ? {} : {
compression: config.compression === 'gzip' ? CompressionAlgorithm.GZIP : CompressionAlgorithm.NONE,
}),
})
const provider = new LoggerProvider({
resource: resourceFromAttributes({
'service.name': config.serviceName,
'service.version': config.serviceVersion,
}),
processors: [new BatchLogRecordProcessor({
maxExportBatchSize: config.maxExportBatchSize,
maxQueueSize: config.maxQueueSize,
scheduledDelayMillis: config.scheduledDelayMillis,
exportTimeoutMillis: config.exportTimeoutMillis,
exporter: {
export: (records, callback) => {
exporter.export(records, (result) => {
// SDK flush/shutdown can resolve after export failure; observe the actual completion.
if (result.code !== ExportResultCode.SUCCESS) ctx.logger.warn('Product telemetry export failed')
callback(result)
})
},
forceFlush: () => exporter.forceFlush(),
shutdown: () => exporter.shutdown(),
},
})],
})
this.logger = provider.getLogger('@deepseek-ai/dsh-product-telemetry-otel')
ctx.effect(() => async () => {
let timer!: ReturnType<typeof setTimeout>
const deadline = new Promise<void>((resolve) => {
timer = setTimeout(() => {
ctx.logger.warn('Product telemetry shutdown deadline exceeded; pending events may be lost')
resolve()
}, config.shutdownTimeoutMillis)
})
try {
await Promise.race([provider.shutdown(), deadline])
} finally {
clearTimeout(timer)
}
})
}
/**
* Enqueue one selected product event without waiting for network delivery.
* Queue admission and shutdown completion are not collector or warehouse acknowledgements.
* @param record - caller-owned event containing only approved analytics fields.
*/
emit(record: ProductTelemetryRecord): void {
const severityNumber = record.severityNumber ?? SeverityNumber.INFO
this.logger.emit({
...record,
observedTimestamp: Date.now(),
severityNumber,
severityText: SeverityNumber[severityNumber],
})
}
}
@@ -0,0 +1,42 @@
/** Loader composition with a loopback collector and one explicit synthetic event. */
import { createServer } from 'node:http'
import { once } from 'node:events'
import { gunzipSync } from 'node:zlib'
import { writeFile } from 'node:fs/promises'
import type {} from '@deepseek-ai/dsh-product-telemetry-otel'
import { bootProductionProfile } from '../../../../test-support/loader-smoke/tests/fixtures/production-profile.ts'
const configPath = process.argv[2]
if (configPath === undefined) throw new Error('product telemetry fixture requires an overlay')
const captures: unknown[] = []
const server = createServer((req, res) => {
const chunks: Buffer[] = []
req.on('data', chunk => chunks.push(chunk as Buffer))
req.on('end', () => {
const bytes = Buffer.concat(chunks)
const body: unknown = JSON.parse(gunzipSync(bytes).toString())
captures.push({ channel: req.headers['x-channel'], compression: req.headers['content-encoding'], body })
res.writeHead(200, { 'content-type': 'application/json' }).end('{}')
})
})
try {
server.listen(0, '127.0.0.1')
await once(server, 'listening')
const address = server.address()
if (address === null || typeof address === 'string') throw new Error('collector has no port')
process.env.DSH_PRODUCT_TELEMETRY_TEST_ENDPOINT = `http://127.0.0.1:${address.port}/v1/logs`
const ctx = await bootProductionProfile({ binName: 'product-telemetry-test', profile: 'headless', overlayPaths: [configPath] })
try {
const telemetry = ctx.get('productTelemetry')
if (telemetry === undefined) throw new Error('product telemetry did not activate')
telemetry.emit({ eventName: 'telemetry.synthetic', timestamp: 1_800_000_000_000, body: 'Synthetic test', attributes: { synthetic: true } })
} finally {
await ctx.fiber.dispose()
}
await writeFile('captures.json', JSON.stringify(captures))
} finally {
const closed = once(server, 'close')
server.close()
server.closeAllConnections()
await closed
}
@@ -0,0 +1,15 @@
- 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-product-telemetry-otel'
config:
endpoint: !!js process.env.DSH_PRODUCT_TELEMETRY_TEST_ENDPOINT
serviceName: dsh-telemetry-test
serviceVersion: test
compression: gzip
scheduledDelayMillis: 60000
@@ -0,0 +1,23 @@
import { readFile } from 'node:fs/promises'
import { resolve } from 'node:path'
import { expect, it } from 'vitest'
import { runLoaderSmoke, LOADER_SMOKE_TEST_TIMEOUT_MS } from '@deepseek-ai/dsh-loader-smoke'
it('exports the explicitly submitted event through the headless Loader composition', async () => {
let captures: unknown
const driver = resolve(import.meta.dirname, 'fixtures/driver.ts')
await runLoaderSmoke({
label: 'product telemetry composition',
tempDirPrefix: 'product-telemetry-',
binScript: driver,
libBinScript: driver,
configPath: resolve(import.meta.dirname, 'fixtures/telemetry.patch.yml'),
tsconfigPath: resolve(import.meta.dirname, '../../../../tsconfig.base.json'),
inspect: async (cwd) => { captures = JSON.parse(await readFile(resolve(cwd, 'captures.json'), 'utf8')) },
})
expect(captures).toMatchObject([{
channel: 'dsh_otel_report', compression: 'gzip',
body: { resourceLogs: [{ scopeLogs: [{ logRecords: [{ eventName: 'telemetry.synthetic', body: { stringValue: 'Synthetic test' } }] }] }] },
}])
expect(JSON.stringify(captures).match(/"eventName"/g)).toHaveLength(1)
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
@@ -0,0 +1,157 @@
import { createServer, type IncomingHttpHeaders } from 'node:http'
import { once } from 'node:events'
import { gunzipSync } from 'node:zlib'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import { LoggerProvider } from '@opentelemetry/sdk-logs'
import { SeverityNumber } from '@opentelemetry/api-logs'
import ProductTelemetry, { Config } from '../src/index.ts'
interface Capture {
headers: IncomingHttpHeaders
body: { resourceLogs: { resource: unknown; scopeLogs: { logRecords: Record<string, unknown>[] }[] }[] }
}
const cleanup: (() => Promise<void>)[] = []
afterEach(async () => {
try {
for (const dispose of cleanup.splice(0).reverse()) await dispose()
} finally {
vi.restoreAllMocks()
vi.useRealTimers()
}
})
async function collector(statuses = [200]) {
const captures: Capture[] = []
const server = createServer((req, res) => {
const chunks: Buffer[] = []
req.on('data', chunk => chunks.push(chunk as Buffer))
req.on('end', () => {
const raw = Buffer.concat(chunks)
const bytes = req.headers['content-encoding'] === 'gzip' ? gunzipSync(raw) : raw
captures.push({ headers: req.headers, body: JSON.parse(bytes.toString()) as Capture['body'] })
res.writeHead(statuses.shift() ?? 200, { 'content-type': 'application/json' }).end('{}')
})
})
cleanup.push(async () => {
const closed = once(server, 'close')
server.close()
server.closeAllConnections()
await closed
})
server.listen(0, '127.0.0.1')
await once(server, 'listening')
const address = server.address()
if (address === null || typeof address === 'string') throw new Error('collector has no port')
return { captures, endpoint: `http://127.0.0.1:${address.port}/v1/logs` }
}
function config(endpoint: string, overrides: Partial<Config> = {}): Config {
return Config({ endpoint, serviceName: 'synthetic-test', serviceVersion: '1', scheduledDelayMillis: 60_000, ...overrides })
}
function context() {
const ctx = new Context()
cleanup.push(() => ctx.fiber.dispose())
return ctx
}
const event = { eventName: 'telemetry.synthetic', body: 'Synthetic test', timestamp: 1_800_000_000_000 }
describe('explicit product telemetry', () => {
it.each(['gzip', 'none', undefined] as const)('drains typed events with %s compression and removes the service', async (compression) => {
const target = await collector()
const ctx = context()
const fiber = await ctx.plugin(ProductTelemetry, config(target.endpoint, compression === undefined ? {} : { compression }))
const service = ctx.productTelemetry
expect(target.captures).toEqual([])
service.emit({ ...event, attributes: { text: 'test', count: 2, duration: 1.5, synthetic: true, model: { provider: 'test', count: 1, enabled: false } } })
service.emit({ ...event, severityNumber: SeverityNumber.ERROR })
await fiber.dispose()
expect(ctx.get('productTelemetry')).toBeUndefined()
service.emit(event)
expect(target.captures).toHaveLength(1)
const [capture] = target.captures
expect(capture?.headers['x-channel']).toBe('dsh_otel_report')
expect(capture?.headers['content-encoding']).toBe(compression === 'gzip' ? 'gzip' : undefined)
const logs = capture?.body.resourceLogs.flatMap(r => r.scopeLogs.flatMap(s => s.logRecords))
expect(logs).toHaveLength(2)
expect(logs?.[0]).toMatchObject({
eventName: event.eventName, body: { stringValue: event.body }, severityNumber: 9,
timeUnixNano: '1800000000000000000',
})
expect(logs?.[0]?.['attributes']).toEqual(expect.arrayContaining([
{ key: 'text', value: { stringValue: 'test' } },
{ key: 'count', value: { intValue: 2 } },
{ key: 'duration', value: { doubleValue: 1.5 } },
{ key: 'synthetic', value: { boolValue: true } },
{ key: 'model', value: { kvlistValue: { values: [{ key: 'provider', value: { stringValue: 'test' } }, { key: 'count', value: { intValue: 1 } }, { key: 'enabled', value: { boolValue: false } }] } } },
]))
expect(Number(logs?.[0]?.['observedTimeUnixNano'])).toBeGreaterThan(0)
expect(logs?.[1]?.['severityNumber']).toBe(17)
expect(JSON.stringify(capture)).not.toContain('user.id')
})
it('does not export on mount or empty shutdown', async () => {
const target = await collector()
const fiber = await context().plugin(ProductTelemetry, config(target.endpoint))
await fiber.dispose()
expect(target.captures).toEqual([])
})
it('exports at the batch threshold and retries a transient rejection', async () => {
const target = await collector([503, 200])
const ctx = context()
await ctx.plugin(ProductTelemetry, config(target.endpoint, { maxExportBatchSize: 1 }))
ctx.productTelemetry.emit(event)
await vi.waitFor(() => { expect(target.captures).toHaveLength(2) }, { timeout: 10_000 })
expect(target.captures[0]?.body).toEqual(target.captures[1]?.body)
})
it('exports a partial batch on its configured interval', async () => {
const target = await collector()
const ctx = context()
await ctx.plugin(ProductTelemetry, config(target.endpoint, { scheduledDelayMillis: 10 }))
ctx.productTelemetry.emit(event)
await vi.waitFor(() => { expect(target.captures).toHaveLength(1) })
})
it('reports a permanent export rejection without failing the caller', async () => {
const target = await collector([400])
const ctx = context()
const warn = vi.spyOn(ctx.logger, 'warn')
const fiber = await ctx.plugin(ProductTelemetry, config(target.endpoint))
expect(() => { ctx.productTelemetry.emit(event) }).not.toThrow()
await fiber.dispose()
expect(target.captures).toHaveLength(1)
expect(warn).toHaveBeenCalledWith('Product telemetry export failed')
})
it.each([
{ endpoint: 'broken' }, { endpoint: 'ftp://collector.test/logs' },
{ maxExportBatchSize: 0 }, { maxExportBatchSize: 2, maxQueueSize: 1 },
{ maxQueueSize: -1 }, { scheduledDelayMillis: 0 }, { exportTimeoutMillis: Infinity },
{ shutdownTimeoutMillis: 2_147_483_648 },
])('rejects invalid configuration %j before exposing the service', (invalid) => {
const ctx = context()
expect(() => new ProductTelemetry(ctx, config('http://collector.test/v1/logs', invalid))).toThrow()
expect(ctx.get('productTelemetry')).toBeUndefined()
})
it('bounds a stalled shutdown and observes its later settlement', async () => {
const target = await collector()
const ctx = context()
const warn = vi.spyOn(ctx.logger, 'warn')
const fiber = await ctx.plugin(ProductTelemetry, config(target.endpoint, { shutdownTimeoutMillis: 20 }))
const pending = Promise.withResolvers<undefined>()
vi.spyOn(LoggerProvider.prototype, 'shutdown').mockReturnValue(pending.promise)
vi.useFakeTimers()
const disposal = fiber.dispose()
await vi.advanceTimersByTimeAsync(20)
await disposal
expect(warn).toHaveBeenCalledWith('Product telemetry shutdown deadline exceeded; pending events may be lost')
pending.resolve(undefined)
await pending.promise
await vi.advanceTimersByTimeAsync(0)
})
})
@@ -0,0 +1,21 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cosmokit"
},
{
"path": "../../../vendor/cordis"
},
{
"path": "../../../vendor/schemastery"
}
]
}
+37
View File
@@ -8172,6 +8172,43 @@ importers:
specifier: workspace:^
version: link:../../typert/protocol
packages/host/product-telemetry-otel:
dependencies:
'@deepseek-ai/schemastery':
specifier: link:../../../vendor/schemastery
version: link:../../../vendor/schemastery
'@opentelemetry/api-logs':
specifier: ^0.220.0
version: 0.220.0
'@opentelemetry/core':
specifier: 2.9.0
version: 2.9.0(@opentelemetry/[email protected])
'@opentelemetry/exporter-logs-otlp-http':
specifier: ^0.220.0
version: 0.220.0(@opentelemetry/[email protected])
'@opentelemetry/otlp-exporter-base':
specifier: ^0.220.0
version: 0.220.0(@opentelemetry/[email protected])
'@opentelemetry/resources':
specifier: ^2.9.0
version: 2.10.0(@opentelemetry/[email protected])
'@opentelemetry/sdk-logs':
specifier: ^0.220.0
version: 0.220.0(@opentelemetry/[email protected])
devDependencies:
'@deepseek-ai/cordis':
specifier: workspace:^
version: link:../../../vendor/cordis
'@deepseek-ai/cordis-plugin-loader':
specifier: workspace:^
version: link:../../../vendor/loader
'@deepseek-ai/dsh-app-boot':
specifier: workspace:^
version: link:../../boot/app-boot
'@deepseek-ai/dsh-loader-smoke':
specifier: workspace:^
version: link:../../test-support/loader-smoke
packages/host/webserver:
dependencies:
'@deepseek-ai/schemastery':
+3
View File
@@ -56,6 +56,7 @@ export { REGION_BEGIN, REGION_END }
export const SERVICE_PAGE: Record<string, string> = {
speechToText: 'voice-input.md',
speechController: 'voice-input.md',
productTelemetry: 'product-telemetry.md',
connection: 'web-server.md',
pluginManager: 'boot.md',
profileContext: 'boot.md',
@@ -266,6 +267,8 @@ export const EVENT_WALK_EXEMPTIONS: Record<string, string> = {
* appear on more than one page.
*/
export const LINK_MAP: Readonly<Record<string, string>> = {
ProductTelemetryRecord: 'product-telemetry.md',
ProductTelemetryScalar: 'product-telemetry.md',
WorkspaceChangesSummary: 'deliverables.md',
WorkspaceFileDiff: 'deliverables.md',
Reload: 'boot.md',
+7
View File
@@ -99,6 +99,13 @@ const GROUP_ORDER = [
]
const SERVICE_ROLES: ServiceRole[] = [
{
key: 'productTelemetry',
pkg: 'product-telemetry-otel',
title: 'Product usage event sender',
mode: 'core',
note: 'Exports explicitly submitted analytics events through OTLP/HTTP; mounting alone collects nothing.',
},
{
key: 'hmr',
pkg: 'hmr',
@@ -49,6 +49,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
'packages/experimental/speech-to-text-sensevoice': { kind: 'none', reason: 'Local recognition returns transient text without modifying model context.' },
'packages/experimental/client-ui-voice-input': { kind: 'none', reason: 'Inserts reviewable text into the unsent draft without submitting to the Agent.' },
'packages/experimental/voice-input-bundle': { kind: 'none', reason: 'Composes dictation and preparation plugins without adding any model-facing contribution.' },
'packages/host/product-telemetry-otel': { kind: 'none', reason: 'The plugin exports explicit analytics records without contributing model context.' },
'packages/api/terminal-controller': { kind: 'none', reason: 'User-owned terminal processes and screen streams never enter model requests or Session events.' },
'packages/client/ui-sidebar-terminal': { kind: 'none', reason: 'The browser renders user terminal screens without exposing them to the model.' },
'packages/ssh/ssh': { kind: 'none', reason: 'The connection owner transports private provider operations; consumers own all model-facing content.' },
+1
View File
@@ -396,6 +396,7 @@
"@deepseek-ai/dsh-plan-mode": ["./packages/plan/plan-mode/src"],
"@deepseek-ai/dsh-plan-mode/invariant": ["./packages/plan/plan-mode/src/invariant.ts"],
"@deepseek-ai/dsh-plugin-package-inventory-deepseek": ["./packages/llm/plugin-package-inventory-deepseek/src"],
"@deepseek-ai/dsh-product-telemetry-otel": ["./packages/host/product-telemetry-otel/src"],
"@deepseek-ai/dsh-ptc-runtime": ["./packages/ptc-runtime/ptc-runtime/src"],
"@deepseek-ai/dsh-ptc-runtime-node": ["./packages/ptc-runtime/ptc-runtime-node/src"],
"@deepseek-ai/dsh-pwsh-sandbox": ["./packages/shell/pwsh-sandbox/src"],
+1
View File
@@ -177,6 +177,7 @@
{ "path": "./packages/experimental/api-speech-to-text" },
{ "path": "./packages/experimental/voice-input-bundle" },
{ "path": "./packages/host/product-telemetry-otel" },
{ "path": "./packages/client/connection/tsconfig.host.json" },
{ "path": "./packages/skill/skill-office" },
{ "path": "./packages/skill/tool-workspace-dependencies" },