feat(telemetry): require explicit opt-in
This commit is contained in:
@@ -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'
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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` 两种显式启用模式。
|
||||
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -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: '继续',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`).
|
||||
|
||||
|
||||
@@ -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`)。
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)}`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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' })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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 配置继续使用上表所示的字符串值。
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user