feat(telemetry): require explicit opt-in

This commit is contained in:
Turtle
2026-08-10 17:45:50 +08:00
parent 504e96a049
commit e6eb44ed2a
53 changed files with 265 additions and 370 deletions

View File

@@ -120,14 +120,12 @@
- id: session-projection
name: '@deepseek-ai/dsh-session-projection'
# Session telemetry, on for every dsh mode: mirrors every session-log
# event (assistant/chunk projected to first-of-step) plus ops markers onto
# OTLP/HTTP log records, streaming on the batch processor's cadence
# (10s/batch here) — not at exit; a crash loses at most the last unexported
# interval. No telemetry/record redaction rule is mounted yet, so exports
# are the raw captured copy; the deployment stance, env seams, and
# follow-ups are pinned in the web-telemetry-default-mount Agent Note.
# DSH_TELEMETRY_OTLP_URL overrides the production endpoint, and a non-empty
# Session telemetry is mounted but disabled by default. DSH_TELEMETRY_MODE
# explicitly opts into FULL or FEEDBACK_ONLY reporting; uploading mirrors
# session-log records onto OTLP/HTTP logs with no telemetry/record redaction
# rule, so exports are the raw captured copy. The deployment stance, env
# seams, and follow-ups are pinned in the default-off Agent Note.
# DSH_TELEMETRY_OTLP_URL overrides the production endpoint. A non-empty
# DSH_TELEMETRY_DISABLED — any value, including '0'/'false' — opts the
# process out (the launchers patch the row disabled; config cannot disable
# a row). Exports carry the harness home's anonymous user id ($DSH_HOME/.userid,
@@ -144,6 +142,7 @@
- id: telemetry-otel
name: '@deepseek-ai/dsh-session-telemetry-otel'
config:
mode: !!js process.env.DSH_TELEMETRY_MODE || 'DISABLED'
shutdownTimeoutMillis: 3000
exporter:
url: !!js process.env.DSH_TELEMETRY_OTLP_URL ?? 'https://harness-telemetry.deepseeksvc.com/v1/logs'

View File

@@ -23,11 +23,14 @@ describe('dsh-base bundle', () => {
)
expect(Array.isArray(parsed)).toBe(true)
// The base layer is one insert list over the empty profile root.
const rows = (parsed as { insert?: { id?: string }[] }[]).flatMap(
const rows = (parsed as { insert?: { id?: string; config?: Record<string, unknown> }[] }[]).flatMap(
patch => patch.insert ?? [],
)
expect(rows.length).toBeGreaterThan(50)
expect(rows.some(row => row.id === 'agent-loop')).toBe(true)
expect(rows.find(row => row.id === 'telemetry-otel')?.config?.['mode']).toEqual({
__jsExpr: "process.env.DSH_TELEMETRY_MODE || 'DISABLED'",
})
})
it('ships the Windows platform layer as the confined pwsh roster over the ACL runner chain', () => {

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/client/ui-settings-general/README.md
README.md: ab27e073dc76335efc619f56365d1705007f7ef2
README.zh.md: 16ff5604bee5425569b783e27a29699344f630e3
README.md: 4fa8f0a854285e42fec415e4bcd7182dde745e2a
README.zh.md: e923da7ce27d5bc6a25ee44269acfd68aeb74983

View File

@@ -6,7 +6,7 @@ Settings ownerless-copy and product-onboarding plugin: registers everything on t
A loopback browser loads the provider's `hasDocument` capability through `settings.describe` and renders **Open configuration file** only when the Host confirms that a provider-owned local document can be prepared. The action sends the pathless, loopback-only `settings.openDocument` request; the Host resolves the provider path again, materializes an absent document, and hands it to a native text editor (`open -t` on macOS, bypassing a browser file association; the desktop file association on Linux and Windows; Windows association after `wslpath -w` translation on WSL). Open failures keep the action available and render a localized error. Reopening the dialog or reconnecting refreshes availability after a transient read failure or Host topology change. Remote browsers never register the action and never issue the privileged settings read.
`src/onboarding-copy.ts` is the single editable owner of the complete notice plus `WELCOME_NOTICE_VERSION`; both supported GUI locales intentionally render the same Chinese copy. The Host half registers `ui-onboarding` in the user-settings seam. A loopback browser compares `welcomeNoticeVersion` for exact equality and writes the current value only after Continue succeeds. The path mutation is idempotent across tabs and preserves sibling settings, while `host/settings-changed` makes an externally acknowledged notice advance without a reload. A non-loopback browser cannot access the privileged settings API: it still presents the notice, but Continue advances only the current browser process and a reload presents the notice again. A different version deliberately presents the notice again. The welcome page preserves every authored paragraph, gives the requested clause in the final paragraph the sole emphasis, initially focuses the title, and has no close, Escape, mask-click, or secondary path. None of its copy or acknowledgement enters a Session log or model request. The notice identifies `DSH_TELEMETRY_DISABLED=1` as the telemetry opt-out.
`src/onboarding-copy.ts` is the single editable owner of the complete notice plus `WELCOME_NOTICE_VERSION`; both supported GUI locales intentionally render the same Chinese copy. The Host half registers `ui-onboarding` in the user-settings seam. A loopback browser compares `welcomeNoticeVersion` for exact equality and writes the current value only after Continue succeeds. The path mutation is idempotent across tabs and preserves sibling settings, while `host/settings-changed` makes an externally acknowledged notice advance without a reload. A non-loopback browser cannot access the privileged settings API: it still presents the notice, but Continue advances only the current browser process and a reload presents the notice again. A different version deliberately presents the notice again. The welcome page preserves every authored paragraph, gives the requested clause in the final paragraph the sole emphasis, initially focuses the title, and has no close, Escape, mask-click, or secondary path. None of its copy or acknowledgement enters a Session log or model request. The notice states that session telemetry is disabled by default and names the `FEEDBACK_ONLY` and `FULL` opt-in modes.
## Model Experience

View File

@@ -6,7 +6,7 @@
回环浏览器通过 `settings.describe` 加载提供方的 `hasDocument` 能力,且只有在 Host 确认可准备好一份由提供方持有的本地文档时才渲染**打开配置文件**。该操作发送无路径参数且仅限回环访问的 `settings.openDocument` 请求Host 会再次解析提供方路径、在文档缺失时将其创建出来并交给原生文本编辑器macOS 上使用 `open -t`绕过浏览器文件关联Linux 和 Windows 上使用桌面文件关联WSL 上经 `wslpath -w` 转换后使用 Windows 文件关联)。打开失败时该操作仍可使用,并渲染本地化错误。临时读取失败或 Host 拓扑变化后,重新打开对话框或重新连接会刷新可用性。远程浏览器从不注册该操作,也从不发起这项特权设置读取。
`src/onboarding-copy.ts` 是完整通知文案和 `WELCOME_NOTICE_VERSION` 的唯一可编辑来源GUI 支持的两种 locale 都有意渲染同一份中文文案。宿主端在用户设置 seam 中注册 `ui-onboarding`。回环浏览器会比较 `welcomeNoticeVersion` 是否精确相等,仅在「继续」操作成功后写入当前值。该路径变更在不同标签页间幂等,并会保留同级设置;`host/settings-changed` 则让页面在通知被外部确认后,无需重新加载即可推进。非回环浏览器不能访问受保护的设置 API它仍会显示通知但「继续」只推进当前浏览器进程重新加载后会再次显示通知。版本不同时系统也会有意重新显示通知。欢迎页保留原文的每个段落仅强调最后一段中指定的句段初始焦点落在标题上并且没有关闭操作、Escape、点击遮罩或次要操作路径。其文案和确认状态均不会进入会话日志或模型请求。通知明确以 `DSH_TELEMETRY_DISABLED=1` 作为遥测关闭方式。
`src/onboarding-copy.ts` 是完整通知文案和 `WELCOME_NOTICE_VERSION` 的唯一可编辑来源GUI 支持的两种 locale 都有意渲染同一份中文文案。宿主端在用户设置 seam 中注册 `ui-onboarding`。回环浏览器会比较 `welcomeNoticeVersion` 是否精确相等,仅在「继续」操作成功后写入当前值。该路径变更在不同标签页间幂等,并会保留同级设置;`host/settings-changed` 则让页面在通知被外部确认后,无需重新加载即可推进。非回环浏览器不能访问受保护的设置 API它仍会显示通知但「继续」只推进当前浏览器进程重新加载后会再次显示通知。版本不同时系统也会有意重新显示通知。欢迎页保留原文的每个段落仅强调最后一段中指定的句段初始焦点落在标题上并且没有关闭操作、Escape、点击遮罩或次要操作路径。其文案和确认状态均不会进入会话日志或模型请求。通知说明会话遥测默认禁用,并列出 `FEEDBACK_ONLY``FULL` 两种显式启用模式。
## 模型体验

View File

@@ -8,7 +8,7 @@ export const WELCOME_NOTICE_ACK_FIELD = 'welcomeNoticeVersion'
* Bump only when the notice changes materially and every user should see it
* again. The acknowledgement is compared for exact equality.
*/
export const WELCOME_NOTICE_VERSION = '2026-07-30.7'
export const WELCOME_NOTICE_VERSION = '2026-08-10.1'
/** The complete editable welcome notice in both supported GUI locales. */
export const WELCOME_NOTICE_COPY = {
@@ -17,7 +17,7 @@ export const WELCOME_NOTICE_COPY = {
paragraphs: [
'感谢您愿意拨冗试用 DeepSeek Harness。当前版本仍处于内部测试阶段功能仍待完善体验难免有些粗糙。',
'“如切如磋,如琢如磨。” 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中发现的问题,也可能促使我们重新审视,甚至推翻已有的设计。',
'为了帮助我们更准确地还原您真实使用中的问题,内测版本默认会上传所有 Session Log如需关闭,可以设置环境变量 DSH_TELEMETRY_DISABLED=1。另外,如果您有任何反馈与建议,请在企业微信群中留言告诉我们。每一条反馈,都会帮助我们把它打磨得更好。',
'内测版本默认会上传 Session Log如需在提交反馈时共享会话日志,可以设置环境变量 DSH_TELEMETRY_MODE=FEEDBACK_ONLY如需持续上传可以设置 DSH_TELEMETRY_MODE=FULL。另外,如果您有任何反馈与建议,请在企业微信群中留言告诉我们。每一条反馈,都会帮助我们把它打磨得更好。',
],
feedbackEmphasis: '如果您有任何反馈与建议,请在企业微信群中留言告诉我们',
continueLabel: '继续',
@@ -27,7 +27,7 @@ export const WELCOME_NOTICE_COPY = {
paragraphs: [
'感谢您愿意拨冗试用 DeepSeek Harness。当前版本仍处于内部测试阶段功能仍待完善体验难免有些粗糙。',
'“如切如磋,如琢如磨。” 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中发现的问题,也可能促使我们重新审视,甚至推翻已有的设计。',
'为了帮助我们更准确地还原您真实使用中的问题,内测版本默认会上传所有 Session Log如需关闭,可以设置环境变量 DSH_TELEMETRY_DISABLED=1。另外,如果您有任何反馈与建议,请在企业微信群中留言告诉我们。每一条反馈,都会帮助我们把它打磨得更好。',
'内测版本默认会上传 Session Log如需在提交反馈时共享会话日志,可以设置环境变量 DSH_TELEMETRY_MODE=FEEDBACK_ONLY如需持续上传可以设置 DSH_TELEMETRY_MODE=FULL。另外,如果您有任何反馈与建议,请在企业微信群中留言告诉我们。每一条反馈,都会帮助我们把它打磨得更好。',
],
feedbackEmphasis: '如果您有任何反馈与建议,请在企业微信群中留言告诉我们',
continueLabel: '继续',

View File

@@ -7,9 +7,9 @@
*/
import {
ConsentResolver,
TelemetryReporter,
buildTelemetryPayload,
resolveTelemetryConsent,
type ConsentDecision,
} from '@deepseek-ai/dsh-telemetry'
@@ -17,7 +17,7 @@ import {
export interface CommandTelemetryEvent {
/** The dsh-sdk command that ran. */
command: string
/** Project directory whose consent, `cordis.yml`, and `package.json` are read. */
/** Project directory whose `cordis.yml` and `package.json` may be reported. */
cwd: string
/** Wall-clock duration in milliseconds. */
durationMs: number
@@ -27,12 +27,12 @@ export interface CommandTelemetryEvent {
/** Injectable consent and delivery hooks for tests. */
export interface CommandTelemetryDeps {
resolve?: (cwd: string) => Promise<ConsentDecision>
resolve?: () => ConsentDecision | Promise<ConsentDecision>
reporter?: Pick<TelemetryReporter, 'report' | 'flush'>
}
/**
* Resolve consent for the project and, when allowed, assemble and send one
* Resolve the shared telemetry mode and, when allowed, assemble and send one
* telemetry event, draining in-flight sends before returning. Swallows every
* error so telemetry can never change a command's result.
* @param event - the command lifecycle facts.
@@ -43,9 +43,8 @@ export async function reportCommandTelemetry(
deps: CommandTelemetryDeps = {},
): Promise<void> {
try {
/* v8 ignore next -- the production ConsentResolver is exercised by the built-bin smoke */
const resolve = deps.resolve ?? (cwd => new ConsentResolver().resolve(cwd))
const consent = await resolve(event.cwd)
/* v8 ignore next -- the production resolver is exercised by its owning tests */
const consent = await (deps.resolve?.() ?? resolveTelemetryConsent())
if (!consent.allowed) return
const payload = await buildTelemetryPayload({
command: event.command,

View File

@@ -611,12 +611,12 @@ describe('command telemetry', () => {
const reporter = { report: () => { sent.push(1) }, flush: async () => {} }
await reportCommandTelemetry(
{ command: 'build', cwd: dir, durationMs: 5, success: true },
{ resolve: async () => ({ allowed: true, reason: 'absent' }), reporter },
{ resolve: async () => ({ allowed: true, reason: 'FULL' }), reporter },
)
expect(sent).toHaveLength(1)
await reportCommandTelemetry(
{ command: 'build', cwd: dir, durationMs: 5, success: true },
{ resolve: async () => ({ allowed: false, reason: 'disabled' }), reporter },
{ resolve: async () => ({ allowed: false, reason: 'DISABLED' }), reporter },
)
expect(sent).toHaveLength(1)
await expect(reportCommandTelemetry(

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/scaffold/telemetry/README.md
README.md: 024ff8724604426d3d77609929ca40341f98e0fb
README.zh.md: 8d69d9289ed9c412e4d38fe9b24bb3222f81f94a
README.md: 670937d70dd2cabc0c77306a6239caa23123ae6f
README.zh.md: 6bbd17a46fe9828b3722c5703c1484eafebb8540

View File

@@ -2,17 +2,17 @@
English | [中文](README.zh.md)
Launcher-side telemetry primitives for the dsh-sdk toolchain. This is a plain library the launcher imports around each command; it is **not** a Cordis plugin, because `build` and first-init `create` never boot Cordis. Wiring the reporter into the launcher command dispatch and adding the telemetry consent feature to the `dsh-helper` catalog live in their owning packages, not here.
Launcher-side telemetry primitives for the dsh-sdk toolchain. This is a plain library the launcher imports around each command; it is not a Cordis plugin because `build` and first-init `create` never boot Cordis. Wiring the reporter into launcher command dispatch lives in its owning package.
| Export | Role |
|---|---|
| `SecretRedactor` | Conservative safety backstop: replaces secret-shaped values (secret-like keys, known token shapes, PEM blocks, URL credentials, high-entropy opaque tokens) with a placeholder in both parsed values (`redactValue`) and raw text (`redactText`). Never drops a field or line. |
| `ConsentResolver` | Parses (never boots) a project `cordis.yml` and reads the telemetry entry's enabled/disabled state as consent; `DO_NOT_TRACK`/CI env force a hard opt-out. |
| `resolveTelemetryConsent` | Reads the shared `DSH_TELEMETRY_MODE`; only `FULL` permits launcher reporting, while `FEEDBACK_ONLY`, `DISABLED`, unset, and empty values deny it. |
| `buildTelemetryPayload` | Assembles `{command, durationMs, success, cordisYmlContent, packageJsonContent}`, running the redactor over the full `cordis.yml` and `package.json` text. Never reads `.env`; `package.json` ships only alongside a `cordis.yml`, so a command run in a non-SDK directory never uploads that directory's unrelated manifest. |
| `getOrCreateAnonymousId` | Random UUID persisted in the harness home resolved by [`@deepseek-ai/dsh-paths`](../../util/paths/README.md) (`$DSH_HOME` > `~/.dsh`), scoped to that home rather than the machine, never derived from git. |
| `TelemetryReporter` | Fire-and-forget send: `report()` never blocks or throws; delivery resolves on every path; `flush()` optionally drains in-flight sends within a cap. |
Consent is carried by the telemetry entry in `cordis.yml`, so disabling telemetry is disabling that entry. Telemetry reports by default and is off only when a present telemetry entry is explicitly `disabled`: a missing `cordis.yml` (first `create`), an enabled entry, or a `cordis.yml` with no telemetry entry all report. `DO_NOT_TRACK`/CI always deny. The no-config and absent-entry defaults are configurable on `ConsentResolver`.
`DSH_TELEMETRY_MODE` is the single positive consent setting for session and launcher telemetry. `FULL` enables this launcher feed; `FEEDBACK_ONLY` keeps command telemetry off and permits only feedback-triggered Session Log sharing; every other supported state keeps this feed off.
The collection endpoint is a fixed constant (`DSH_TELEMETRY_ENDPOINT`).

View File

@@ -2,17 +2,17 @@
[English](README.md) | 中文
用于 dsh-sdk 工具链的启动器侧 telemetry 原语。这是启动器在执行每个命令时导入的普通库;它**不是** Cordis 插件,因为 `build` 与首次初始化的 `create` 从不启动 Cordis。将 reporter 接入启动器命令分发,并把 telemetry consent 功能加入 `dsh-helper` 目录,属于各自所属包的职责,而不是此包的职责。
用于 dsh-sdk 工具链的启动器侧 telemetry 原语。这是启动器在执行每个命令时导入的普通库;它不是 Cordis 插件,因为 `build` 与首次初始化的 `create` 从不启动 Cordis。将 reporter 接入启动器命令分发属于其所在包的职责。
| 导出 | 职责 |
|---|---|
| `SecretRedactor` | 保守的安全后备:在已解析值(`redactValue`)与原始文本(`redactText`)中,将形似密钥的值(疑似密钥的键名、已知 token 格式、PEM 块、URL 凭据、高熵不透明 token替换为占位符。绝不删除字段或行。 |
| `ConsentResolver` | 解析项目 `cordis.yml`(绝不启动),读取 telemetry 配置项的启用/禁用状态作为 consent`DO_NOT_TRACK`CI 环境会强制完全停止上报。 |
| `resolveTelemetryConsent` | 读取共享的 `DSH_TELEMETRY_MODE`;只有 `FULL` 允许启动器上报,`FEEDBACK_ONLY``DISABLED`、未设置和空值都会拒绝。 |
| `buildTelemetryPayload` | 组装 `{command, durationMs, success, cordisYmlContent, packageJsonContent}`,对完整的 `cordis.yml``package.json` 文本运行 redactor。绝不读取 `.env`;发送 `package.json` 的前提是同时存在 `cordis.yml`,因此在非 SDK 目录运行的命令不会上传该目录中无关的 manifest元数据清单。 |
| `getOrCreateAnonymousId` | 将随机 UUID 持久化到 [`@deepseek-ai/dsh-paths`](../../util/paths/README.md) 解析出的 harness home`$DSH_HOME` > `~/.dsh`);其范围限定为该 home而不是整台机器且绝不从 git 派生。 |
| `TelemetryReporter` | 即发即弃发送:`report()` 绝不阻塞或抛出;无论经过哪条路径,发送操作最终都会结束;`flush()` 可以在上限内排空进行中的发送。 |
Consent 由 `cordis.yml` 中的 telemetry 配置项承载,因此禁用 telemetry 就是禁用该配置项。telemetry 默认上报,只有已经存在的 telemetry 配置项被显式设为 `disabled` 时才关闭:缺少 `cordis.yml`(首次 `create`)、配置项已启用,或 `cordis.yml` 中没有 telemetry 配置项时都会上报。`DO_NOT_TRACK`CI 始终拒绝。无配置与缺少配置项的默认值可以通过 `ConsentResolver` 配置
`DSH_TELEMETRY_MODE` 是会话与启动器 telemetry 的唯一正向授权配置。`FULL` 启用该启动器数据流;`FEEDBACK_ONLY` 保持命令 telemetry 关闭,只允许由反馈触发的 Session Log 共享;其他受支持的状态都会保持该数据流关闭
收集端点是固定常量(`DSH_TELEMETRY_ENDPOINT`)。

View File

@@ -24,9 +24,6 @@
"lib/types/**/*.d.ts"
],
"license": "BSD-3-Clause",
"dependencies": {
"yaml": "^2.9.0"
},
"peerDependencies": {
"@deepseek-ai/dsh-brand": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",

View File

@@ -1,42 +1,18 @@
/**
* Consent resolution for dsh-sdk telemetry.
* Consent resolution for dsh-sdk launcher telemetry.
*
* Telemetry is OFF only when `cordis.yml` contains a telemetry entry that is
* explicitly `disabled`; every other file state reports (no `cordis.yml`, an
* enabled entry, or no telemetry entry at all). The resolver PARSES `cordis.yml`
* — it never boots a Cordis application — because several launcher commands
* (`build`, `create`) never boot Cordis at all. `DO_NOT_TRACK` and CI
* environment signals force a denial regardless of file state.
* `DSH_TELEMETRY_MODE` is the shared consent setting for session and launcher
* telemetry. Only `FULL` permits launcher reporting; unset and empty values
* resolve to `DISABLED`.
*
* @module @deepseek-ai/dsh-telemetry/consent-resolver
*/
import { readFile } from 'node:fs/promises'
import { join } from 'node:path'
import { parseDocument, type ScalarTag } from 'yaml'
/** Default `cordis.yml` entry name that carries telemetry consent. */
export const DEFAULT_TELEMETRY_PLUGIN_NAME = '@deepseek-ai/dsh-telemetry'
/**
* Passthrough for Cordis' `!!js` expression tag so parsing consent never fails
* on projects that inline JavaScript expressions; the resolver only reads plain
* `name`/`disabled` scalars and does not evaluate expressions.
*/
const JS_EXPRESSION_TAG: ScalarTag = {
tag: 'tag:yaml.org,2002:js',
resolve: value => value,
}
/** Why telemetry is or is not permitted for one command. */
export type ConsentReason =
| 'enabled'
| 'disabled'
| 'absent'
| 'no-config'
| 'do-not-track'
| 'ci'
| 'unreadable'
| 'FULL'
| 'FEEDBACK_ONLY'
| 'DISABLED'
/** Resolved telemetry consent for one command invocation. */
export interface ConsentDecision {
@@ -46,80 +22,21 @@ export interface ConsentDecision {
reason: ConsentReason
}
/** Tuning for {@link ConsentResolver}; every field defaults to a documented value. */
export interface ConsentResolverOptions {
/** `cordis.yml` entry name whose enabled state carries consent. */
telemetryPluginName?: string
/** Environment used for `DO_NOT_TRACK`/CI checks; defaults to `process.env`. */
env?: NodeJS.ProcessEnv
/** Honor `DO_NOT_TRACK`/CI env signals as a hard opt-out. Defaults to `true`. */
honorEnvOptOut?: boolean
/** Consent when `cordis.yml` does not exist yet (first `create`). Defaults to `true` (telemetry is default-on). */
allowWhenNoConfig?: boolean
/** Consent when `cordis.yml` exists but has no telemetry entry. Defaults to `true` (report unless a present entry is disabled). */
allowWhenEntryAbsent?: boolean
}
/** Whether an environment variable is set to a non-empty, non-"0"/"false" value. */
function envEnabled(value: string | undefined): boolean {
if (value === undefined) return false
const normalized = value.trim().toLowerCase()
return normalized.length > 0 && normalized !== '0' && normalized !== 'false'
}
/** Read a `cordis.yml` entry's `name`/`disabled` scalars, tolerating `!!js` tags. */
function readTelemetryEntry(text: string, pluginName: string): { present: boolean; disabled: boolean } {
const document = parseDocument(text, { customTags: [JS_EXPRESSION_TAG] })
const contents: unknown = document.toJS({ maxAliasCount: -1 })
if (!Array.isArray(contents)) return { present: false, disabled: false }
for (const entry of contents) {
if (entry === null || typeof entry !== 'object') continue
const record = entry as Record<string, unknown>
if (record.name === pluginName) return { present: true, disabled: record.disabled === true }
}
return { present: false, disabled: false }
}
/** Resolve telemetry consent by parsing a project's `cordis.yml` and the environment. */
export class ConsentResolver {
readonly #pluginName: string
readonly #env: NodeJS.ProcessEnv
readonly #honorEnvOptOut: boolean
readonly #allowWhenNoConfig: boolean
readonly #allowWhenEntryAbsent: boolean
/** @param options - plugin name, environment, and default-decision knobs. */
constructor(options: ConsentResolverOptions = {}) {
this.#pluginName = options.telemetryPluginName ?? DEFAULT_TELEMETRY_PLUGIN_NAME
this.#env = options.env ?? process.env
this.#honorEnvOptOut = options.honorEnvOptOut ?? true
this.#allowWhenNoConfig = options.allowWhenNoConfig ?? true
this.#allowWhenEntryAbsent = options.allowWhenEntryAbsent ?? true
}
/**
* Resolve consent for a command run in the given project directory.
* @param projectDir - absolute or relative project root containing `cordis.yml`.
* @returns the consent decision and the signal that produced it.
*/
async resolve(projectDir: string): Promise<ConsentDecision> {
if (this.#honorEnvOptOut) {
if (envEnabled(this.#env.DO_NOT_TRACK)) return { allowed: false, reason: 'do-not-track' }
if (envEnabled(this.#env.CI)) return { allowed: false, reason: 'ci' }
}
let text: string
try {
text = await readFile(join(projectDir, 'cordis.yml'), 'utf8')
} catch (error) {
// Missing cordis.yml is the first-init (`create`) path; any other read
// fault is treated conservatively as its own reason.
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return { allowed: this.#allowWhenNoConfig, reason: 'no-config' }
}
return { allowed: false, reason: 'unreadable' }
}
const entry = readTelemetryEntry(text, this.#pluginName)
if (!entry.present) return { allowed: this.#allowWhenEntryAbsent, reason: 'absent' }
return entry.disabled ? { allowed: false, reason: 'disabled' } : { allowed: true, reason: 'enabled' }
/**
* Resolve launcher telemetry consent from the shared telemetry mode.
* @param env - Environment containing `DSH_TELEMETRY_MODE`; defaults to `process.env`.
* @returns Whether launcher telemetry may report and the resolved mode.
*/
export function resolveTelemetryConsent(env: NodeJS.ProcessEnv = process.env): ConsentDecision {
const mode = env.DSH_TELEMETRY_MODE || 'DISABLED'
switch (mode) {
case 'FULL':
return { allowed: true, reason: 'FULL' }
case 'FEEDBACK_ONLY':
return { allowed: false, reason: 'FEEDBACK_ONLY' }
case 'DISABLED':
return { allowed: false, reason: 'DISABLED' }
default:
throw new Error(`unsupported DSH_TELEMETRY_MODE ${JSON.stringify(mode)}`)
}
}

View File

@@ -2,10 +2,9 @@
* Launcher-side telemetry for the dsh-sdk toolchain: secret redaction, consent
* resolution, anonymous id, payload assembly, and a fire-and-forget reporter.
*
* This package is a plain library the launcher imports around each command it
* is NOT a Cordis plugin (several commands never boot Cordis). Wiring it into
* the launcher command dispatch and the helper feature catalog lives outside
* this package.
* This package is a plain library the launcher imports around each command; it
* is not a Cordis plugin because several commands never boot Cordis. Wiring
* reporting into launcher command dispatch lives outside this package.
*
* FIXME: rename to `@deepseek-ai/dsh-sdk-telemetry` before the first tagged release —
* the current name collides with the `dsh-session-telemetry` family; this is
@@ -24,13 +23,11 @@ export {
} from './secret-redactor.ts'
export type { SecretRedactorOptions } from './secret-redactor.ts'
export {
ConsentResolver,
DEFAULT_TELEMETRY_PLUGIN_NAME,
resolveTelemetryConsent,
} from './consent-resolver.ts'
export type {
ConsentDecision,
ConsentReason,
ConsentResolverOptions,
} from './consent-resolver.ts'
export {
ANONYMOUS_ID_FILE_NAME,

View File

@@ -1,132 +1,41 @@
import { mkdtemp, mkdir, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { ConsentResolver, DEFAULT_TELEMETRY_PLUGIN_NAME, type ConsentDecision } from '@deepseek-ai/dsh-telemetry'
import { describe, expect, it } from 'vitest'
import {
resolveTelemetryConsent,
type ConsentDecision,
} from '@deepseek-ai/dsh-telemetry'
const dirs: string[] = []
async function projectDir(cordisYml?: string): Promise<string> {
const dir = await mkdtemp(join(tmpdir(), 'dsh-consent-'))
dirs.push(dir)
if (cordisYml !== undefined) await writeFile(join(dir, 'cordis.yml'), cordisYml, 'utf8')
return dir
}
afterEach(async () => {
await Promise.all(dirs.splice(0).map(dir => import('node:fs/promises').then(fs => fs.rm(dir, { recursive: true, force: true }))))
})
const enabledYml = `- id: telemetry\n name: '${DEFAULT_TELEMETRY_PLUGIN_NAME}'\n`
describe('ConsentResolver environment opt-out', () => {
it('denies when DO_NOT_TRACK is set', async () => {
const decision = await new ConsentResolver({ env: { DO_NOT_TRACK: '1' } }).resolve(await projectDir(enabledYml))
expect(decision).toEqual<ConsentDecision>({ allowed: false, reason: 'do-not-track' })
describe('resolveTelemetryConsent', () => {
it('denies launcher telemetry when the shared mode is unset or empty', () => {
expect(resolveTelemetryConsent({})).toEqual<ConsentDecision>({ allowed: false, reason: 'DISABLED' })
expect(resolveTelemetryConsent({ DSH_TELEMETRY_MODE: '' }))
.toEqual<ConsentDecision>({ allowed: false, reason: 'DISABLED' })
})
it('denies when CI is set', async () => {
const decision = await new ConsentResolver({ env: { CI: 'true' } }).resolve(await projectDir(enabledYml))
expect(decision).toEqual<ConsentDecision>({ allowed: false, reason: 'ci' })
it('denies launcher telemetry in DISABLED and FEEDBACK_ONLY modes', () => {
expect(resolveTelemetryConsent({ DSH_TELEMETRY_MODE: 'DISABLED' }))
.toEqual<ConsentDecision>({ allowed: false, reason: 'DISABLED' })
expect(resolveTelemetryConsent({ DSH_TELEMETRY_MODE: 'FEEDBACK_ONLY' }))
.toEqual<ConsentDecision>({ allowed: false, reason: 'FEEDBACK_ONLY' })
})
it('ignores falsy env values and continues to the file', async () => {
const decision = await new ConsentResolver({ env: { DO_NOT_TRACK: '0', CI: 'false' } })
.resolve(await projectDir(enabledYml))
expect(decision).toEqual<ConsentDecision>({ allowed: true, reason: 'enabled' })
it('allows launcher telemetry only in FULL mode', () => {
expect(resolveTelemetryConsent({ DSH_TELEMETRY_MODE: 'FULL' }))
.toEqual<ConsentDecision>({ allowed: true, reason: 'FULL' })
})
it('can be told to ignore env opt-out signals', async () => {
const decision = await new ConsentResolver({ env: { DO_NOT_TRACK: '1' }, honorEnvOptOut: false })
.resolve(await projectDir(enabledYml))
expect(decision).toEqual<ConsentDecision>({ allowed: true, reason: 'enabled' })
it('rejects an unsupported non-empty mode', () => {
expect(() => resolveTelemetryConsent({ DSH_TELEMETRY_MODE: 'full' }))
.toThrow('unsupported DSH_TELEMETRY_MODE "full"')
})
it('reads process.env by default', async () => {
const saved = { CI: process.env.CI, DO_NOT_TRACK: process.env.DO_NOT_TRACK }
delete process.env.CI
delete process.env.DO_NOT_TRACK
it('reads process.env by default', () => {
const saved = process.env.DSH_TELEMETRY_MODE
process.env.DSH_TELEMETRY_MODE = 'FULL'
try {
const decision = await new ConsentResolver().resolve(await projectDir(enabledYml))
expect(decision).toEqual<ConsentDecision>({ allowed: true, reason: 'enabled' })
expect(resolveTelemetryConsent()).toEqual<ConsentDecision>({ allowed: true, reason: 'FULL' })
} finally {
if (saved.CI !== undefined) process.env.CI = saved.CI
if (saved.DO_NOT_TRACK !== undefined) process.env.DO_NOT_TRACK = saved.DO_NOT_TRACK
if (saved === undefined) delete process.env.DSH_TELEMETRY_MODE
else process.env.DSH_TELEMETRY_MODE = saved
}
})
})
describe('ConsentResolver cordis.yml state', () => {
const resolver = new ConsentResolver({ env: {} })
it('allows when the telemetry entry is enabled', async () => {
expect(await resolver.resolve(await projectDir(enabledYml)))
.toEqual<ConsentDecision>({ allowed: true, reason: 'enabled' })
})
it('denies when the telemetry entry is disabled', async () => {
const yml = `- id: telemetry\n name: '${DEFAULT_TELEMETRY_PLUGIN_NAME}'\n disabled: true\n`
expect(await resolver.resolve(await projectDir(yml)))
.toEqual<ConsentDecision>({ allowed: false, reason: 'disabled' })
})
it('tolerates !!js expression tags while reading plain scalars', async () => {
const yml = [
'- id: telemetry',
` name: '${DEFAULT_TELEMETRY_PLUGIN_NAME}'`,
'- id: llm',
' name: \'@deepseek-ai/dsh-llm-deepseek\'',
' config:',
' apiKeyEnv: DEEPSEEK_API_KEY',
' model: !!js process.env.DEEPSEEK_MODEL',
'',
].join('\n')
expect(await resolver.resolve(await projectDir(yml)))
.toEqual<ConsentDecision>({ allowed: true, reason: 'enabled' })
})
it('reports (allows) when cordis.yml has no telemetry entry', async () => {
const yml = '- id: llm\n name: \'@deepseek-ai/dsh-llm-deepseek\'\n'
expect(await resolver.resolve(await projectDir(yml)))
.toEqual<ConsentDecision>({ allowed: true, reason: 'absent' })
})
it('can be told to deny when the entry is absent', async () => {
const yml = '- id: llm\n name: \'@deepseek-ai/dsh-llm-deepseek\'\n'
const decision = await new ConsentResolver({ env: {}, allowWhenEntryAbsent: false }).resolve(await projectDir(yml))
expect(decision).toEqual<ConsentDecision>({ allowed: false, reason: 'absent' })
})
it('skips non-object sequence items and a non-sequence root, still reporting absent', async () => {
expect(await resolver.resolve(await projectDir('- just-a-string\n- id: x\n name: y\n')))
.toEqual<ConsentDecision>({ allowed: true, reason: 'absent' })
expect(await resolver.resolve(await projectDir('root: not-a-sequence\n')))
.toEqual<ConsentDecision>({ allowed: true, reason: 'absent' })
})
it('honors a custom telemetry plugin name', async () => {
const yml = '- id: t\n name: \'my-consent-marker\'\n'
const decision = await new ConsentResolver({ env: {}, telemetryPluginName: 'my-consent-marker' })
.resolve(await projectDir(yml))
expect(decision).toEqual<ConsentDecision>({ allowed: true, reason: 'enabled' })
})
})
describe('ConsentResolver missing or unreadable cordis.yml', () => {
it('reports no-config and allows by default on first init', async () => {
expect(await new ConsentResolver({ env: {} }).resolve(await projectDir()))
.toEqual<ConsentDecision>({ allowed: true, reason: 'no-config' })
})
it('can deny on first init', async () => {
const decision = await new ConsentResolver({ env: {}, allowWhenNoConfig: false }).resolve(await projectDir())
expect(decision).toEqual<ConsentDecision>({ allowed: false, reason: 'no-config' })
})
it('denies with an unreadable reason when cordis.yml is not a regular file', async () => {
const dir = await projectDir()
await mkdir(join(dir, 'cordis.yml')) // a directory where the resolver expects a file
expect(await new ConsentResolver({ env: {} }).resolve(dir))
.toEqual<ConsentDecision>({ allowed: false, reason: 'unreadable' })
})
})

View File

@@ -9,8 +9,8 @@ import {
type TelemetryPayload,
} from '@deepseek-ai/dsh-telemetry'
const ALLOW: ConsentDecision = { allowed: true, reason: 'enabled' }
const DENY: ConsentDecision = { allowed: false, reason: 'disabled' }
const ALLOW: ConsentDecision = { allowed: true, reason: 'FULL' }
const DENY: ConsentDecision = { allowed: false, reason: 'DISABLED' }
const anon = (value = 'anon-123'): (() => Promise<AnonymousId>) => async () => value as AnonymousId
function okResponse(): Response {

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/session/session-telemetry-otel/README.md
README.md: 585995ce409255df9608bc33b76625374bc67669
README.zh.md: 7f0b93363fbb4aebb80f0d3cc8108e58ce3f647f
README.md: 4220ed0fc27db4535ae4739d6fe784ca17518535
README.zh.md: e0f33a7df2fcd29c79fc894b9781c8b43e2fc547

View File

@@ -10,7 +10,7 @@ The OpenTelemetry backend for [the telemetry seam](../session-telemetry/) — th
- id: telemetry-otel
name: '@deepseek-ai/dsh-session-telemetry-otel'
config:
mode: FULL # FULL (default), FEEDBACK_ONLY, or DISABLED
mode: FULL # explicit opt-in; default: DISABLED
shutdownTimeoutMillis: 3000 # optional; defaults to 3000
exporter: # passed verbatim to the SDK's OTLP/HTTP log exporter
url: https://collector.example.com/v1/logs
@@ -21,9 +21,9 @@ The OpenTelemetry backend for [the telemetry seam](../session-telemetry/) — th
| `mode` | Behavior |
|---|---|
| `FULL` | Default. Each projected record, including lifecycle ops records, is handed to the OTel SDK immediately. |
| `FULL` | Each projected record, including lifecycle ops records, is handed to the OTel SDK immediately. |
| `FEEDBACK_ONLY` | Each `feedback/record` replays, projects, and redacts the canonical session-log suffix through that event. Later records wait for another feedback event and remain local if none arrives. |
| `DISABLED` | No coordinator, provider, processor, or exporter is constructed. No telemetry record leaves the process. A `feedback/record` logs `session telemetry is DISABLED; nothing will be shared and this feedback remains local`; the event remains in the local session log. |
| `DISABLED` | Default. No coordinator, provider, processor, or exporter is constructed. No telemetry record leaves the process. A `feedback/record` logs `session telemetry is DISABLED; nothing will be shared and this feedback remains local`; the event remains in the local session log. |
Programmatic TypeScript configuration uses the exported `TelemetryMode` enum (`TelemetryMode.FULL`, `TelemetryMode.FEEDBACK_ONLY`, or `TelemetryMode.DISABLED`); raw string literals are not assignable. Serialized Cordis configuration continues to use the string values shown above.

View File

@@ -10,7 +10,7 @@
- id: telemetry-otel
name: '@deepseek-ai/dsh-session-telemetry-otel'
config:
mode: FULL # FULL (default), FEEDBACK_ONLY, or DISABLED
mode: FULL # explicit opt-in; default: DISABLED
shutdownTimeoutMillis: 3000 # optional; defaults to 3000
exporter: # passed verbatim to the SDK's OTLP/HTTP log exporter
url: https://collector.example.com/v1/logs
@@ -21,9 +21,9 @@
| `mode` | 行为 |
|---|---|
| `FULL` | 默认值。每条已投影记录都立即交给 OTel SDK包括生命周期运维记录。 |
| `FULL` | 每条已投影记录都立即交给 OTel SDK包括生命周期运维记录。 |
| `FEEDBACK_ONLY` | 每个 `feedback/record` 都会回放权威会话日志中截至该事件的后缀,并进行投影与脱敏。后续记录等待下一个反馈事件;如果没有后续反馈,则留在本地。 |
| `DISABLED` | 不构造协调器、提供方、处理器或导出器。没有遥测记录会离开进程。`feedback/record` 会记录 `session telemetry is DISABLED; nothing will be shared and this feedback remains local`;该事件留在本地会话日志中。 |
| `DISABLED` | 默认值。不构造协调器、提供方、处理器或导出器。没有遥测记录会离开进程。`feedback/record` 会记录 `session telemetry is DISABLED; nothing will be shared and this feedback remains local`;该事件留在本地会话日志中。 |
程序化 TypeScript 配置使用导出的 `TelemetryMode` 枚举(`TelemetryMode.FULL``TelemetryMode.FEEDBACK_ONLY``TelemetryMode.DISABLED`);原始字符串字面量不可赋值。序列化后的 Cordis 配置继续使用上表所示的字符串值。

View File

@@ -47,7 +47,7 @@ export enum TelemetryMode {
}
/** Default session-sharing policy for schema and direct construction. */
export const DEFAULT_TELEMETRY_MODE = TelemetryMode.FULL
export const DEFAULT_TELEMETRY_MODE = TelemetryMode.DISABLED
const DISABLED_FEEDBACK_WARNING = 'session telemetry is DISABLED; nothing will be shared and this feedback remains local'
const NON_CANONICAL_FEEDBACK_WARNING = 'session telemetry ignored a feedback event absent from the canonical session log'
@@ -77,7 +77,7 @@ function assertNever(value: never): never {
* and shutdown deadline at plugin load; `DISABLED` reads neither.
*/
export interface Config {
/** Sharing policy; defaults to immediate `FULL` delivery. */
/** Sharing policy; defaults to local-only `DISABLED` behavior. */
mode?: TelemetryMode
/**
* Passed verbatim to the SDK's OTLP/HTTP log exporter — the complete

View File

@@ -99,6 +99,7 @@ async function boot(url: string) {
const ctx = new Context()
await ctx.plugin(SessionStore)
const fiber = await ctx.plugin(TelemetryOtel, {
mode: TelemetryMode.FULL,
exporter: { url, headers: { authorization: 'Bearer test-token' } },
})
return { ctx, fiber }
@@ -180,6 +181,7 @@ describe('TelemetryOtel wire', () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const fiber = await ctx.plugin(TelemetryOtel, {
mode: TelemetryMode.FULL,
exporter: { url },
processor: { scheduledDelayMillis: 10 },
})
@@ -210,6 +212,7 @@ describe('TelemetryOtel wire', () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const fiber = await ctx.plugin(TelemetryOtel, {
mode: TelemetryMode.FULL,
exporter: { url, timeoutMillis: 60_000 },
processor: { scheduledDelayMillis: 10, exportTimeoutMillis: 60_000 },
shutdownTimeoutMillis: 50,
@@ -238,6 +241,7 @@ describe('TelemetryOtel wire', () => {
// verbatim passthrough must hand it (and every other field) to the
// exporter rather than silently rebuilding url/headers only.
const fiber = await ctx.plugin(TelemetryOtel, {
mode: TelemetryMode.FULL,
exporter: { url, compression: 'gzip' },
} as Config)
const session = ctx.sessions.create(SessionId('gzip'), { meta: {} })
@@ -364,16 +368,24 @@ describe('TelemetryOtel wire', () => {
expect(captures).toEqual([])
})
it('defaults direct construction to full delivery', async () => {
it('defaults direct construction to disabled delivery', async () => {
const { url, captures } = await mockCollector()
const ctx = new Context()
await ctx.plugin(SessionStore)
new TelemetryOtel(ctx, { exporter: { url } })
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
new TelemetryOtel(ctx, {
exporter: { url },
processor: { maxExportBatchSize: 0 },
})
const session = ctx.sessions.create(SessionId('direct-default'), { meta: {} })
session.append('turn/start', { turn: 1 })
recordFeedback(session, 'local report')
await ctx.fiber.dispose()
expect(eventTypes(captures)).toContain('turn/start')
expect(warn).toHaveBeenCalledWith(
'session telemetry is DISABLED; nothing will be shared and this feedback remains local',
)
expect(captures).toEqual([])
})
})
@@ -382,23 +394,23 @@ describe('TelemetryOtel config fails loud', () => {
expectTypeOf<Config['mode']>().toEqualTypeOf<TelemetryMode | undefined>()
expectTypeOf<'FULL'>().not.toExtend<TelemetryMode>()
expectTypeOf<TelemetryMode.FULL>().toExtend<TelemetryMode>()
expect(DEFAULT_TELEMETRY_MODE).toBe(TelemetryMode.FULL)
expect(DEFAULT_TELEMETRY_MODE).toBe(TelemetryMode.DISABLED)
expect(Config({}).mode).toBe(DEFAULT_TELEMETRY_MODE)
})
it.each([
[{}, /exporter\.url is required/],
[{ exporter: { url: '' } }, /exporter\.url is required/],
[{ exporter: { url: 'not a url' } }, /not a valid URL/],
[{ exporter: { url: 'ftp://collector' } }, /must be http\(s\)/],
[{ mode: TelemetryMode.FULL }, /exporter\.url is required/],
[{ mode: TelemetryMode.FULL, exporter: { url: '' } }, /exporter\.url is required/],
[{ mode: TelemetryMode.FULL, exporter: { url: 'not a url' } }, /not a valid URL/],
[{ mode: TelemetryMode.FULL, exporter: { url: 'ftp://collector' } }, /must be http\(s\)/],
[{ mode: TelemetryMode.FEEDBACK_ONLY }, /exporter\.url is required/],
[{ mode: 'INVALID' }, /INVALID/],
// The SDK accepts a non-positive batch size but its shutdown drain then
// splices empty batches forever — dispose would hang, so reject at load.
[{ exporter: { url: 'http://c/v1/logs' }, processor: { maxExportBatchSize: 0 } }, /maxExportBatchSize/],
[{ exporter: { url: 'http://c/v1/logs' }, processor: { maxExportBatchSize: 0.5 } }, /maxExportBatchSize/],
[{ exporter: { url: 'http://c/v1/logs' }, shutdownTimeoutMillis: 0 }, /shutdownTimeoutMillis/],
[{ exporter: { url: 'http://c/v1/logs' }, shutdownTimeoutMillis: Number.POSITIVE_INFINITY }, /shutdownTimeoutMillis/],
[{ mode: TelemetryMode.FULL, exporter: { url: 'http://c/v1/logs' }, processor: { maxExportBatchSize: 0 } }, /maxExportBatchSize/],
[{ mode: TelemetryMode.FULL, exporter: { url: 'http://c/v1/logs' }, processor: { maxExportBatchSize: 0.5 } }, /maxExportBatchSize/],
[{ mode: TelemetryMode.FULL, exporter: { url: 'http://c/v1/logs' }, shutdownTimeoutMillis: 0 }, /shutdownTimeoutMillis/],
[{ mode: TelemetryMode.FULL, exporter: { url: 'http://c/v1/logs' }, shutdownTimeoutMillis: Number.POSITIVE_INFINITY }, /shutdownTimeoutMillis/],
])('rejects %j at plugin load', async (config, message) => {
const ctx = new Context()
await ctx.plugin(SessionStore)
@@ -463,7 +475,7 @@ describe('dsh-session-telemetry-otel real-load-path guard', () => {
const unwrapped = loader.unwrapExports(module) as Parameters<Context['plugin']>[0]
const ctx = new Context()
await ctx.plugin(SessionStore)
const fiber = await ctx.plugin(unwrapped, { exporter: { url } })
const fiber = await ctx.plugin(unwrapped, { mode: TelemetryMode.FULL, exporter: { url } })
expect(ctx.telemetry).toBeInstanceOf(TelemetryOtel)
await fiber.dispose()
})