From 560ce3b539bc24ea994cfbe9476c21ecd834162e Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 17 Jul 2026 20:28:31 +0800 Subject: [PATCH] feat(dsh-sdk): launcher telemetry reporting around every command Wrap runDshSdkCommand so each command times itself and, in a finally block, resolves consent (option A) and sends one best-effort, fire-and-forget telemetry event (redacted cordis.yml + package.json content; never reads .env). Never affects the command's exit code. Adds dsh-scripts -> dsh-telemetry dependency. Default-on via absent consent entry; opt-out by a disabled telemetry entry. The config/create wizard opt-out toggle is deferred (see design doc). --- docs/sdk-后续工作-设计.md | 3 ++ packages/sdk/scripts/package.json | 1 + packages/sdk/scripts/src/command.ts | 15 +++++- packages/sdk/scripts/src/telemetry.ts | 63 ++++++++++++++++++++++ packages/sdk/scripts/tests/scripts.spec.ts | 47 ++++++++++++++++ packages/sdk/scripts/tsconfig.json | 1 + pnpm-lock.yaml | 3 ++ 7 files changed, 132 insertions(+), 1 deletion(-) create mode 100644 packages/sdk/scripts/src/telemetry.ts diff --git a/docs/sdk-后续工作-设计.md b/docs/sdk-后续工作-设计.md index d281052cee..1395ece5e0 100644 --- a/docs/sdk-后续工作-设计.md +++ b/docs/sdk-后续工作-设计.md @@ -133,6 +133,9 @@ SDK 初版(`packages/sdk/*`)已经落地三个包: - **endpoint**:内置在代码里。 - **consent 承载**:遥测作为 `create` 时默认打开的 feature 写进 cordis.yml(对用户可见、随项目)。 +> **接线现状(读码修正)**:launcher 上报已接通——`runDshSdkCommand` 计时包住每条命令,`finally` 里 resolve consent(甲)→ 建 redacted payload(cordis.yml+package.json 全文、不读 .env)→ fire-and-forget 上报 + flush,best-effort 永不影响命令结果。**默认开**:无遥测条目 → 甲 → 上报。**opt-out 现状**:在 cordis.yml 手动加一条 `disabled` 的 `@deepseek-ai/dsh-telemetry` 条目即关(`ConsentResolver` 读到 disabled → 不报;disabled 条目 cordis 不加载,故不会因"它不是运行时插件"而 boot 失败)。 +> **暂缓(催表 opt-out 开关)**:把"关遥测"做成 config/create 向导里的勾选项还没做。关键约束:`@deepseek-ai/dsh-telemetry` 是 **launcher 库、不是 cordis 运行时插件**,所以 consent 条目只能以 **disabled 形态**存在(enabled=无条目=甲默认报;要关才写 disabled 条目),不能像普通 feature 那样挂一个 enabled 的可 boot 条目。向导化这个"只在关闭时才出现条目"的特殊语义留作后续。 + **在案取舍**:发全文会把第三方(含私有 scoped)包名、cordis 配置值(base-url/路径)暴露给 endpoint 持有方;主流工具都不发这些(Turbo 排除包名、Angular 禁模块名)。ccyu 作为本 SDK 维护者接受此暴露——目的即掌握开发者用了哪些 plugin/依赖/配置。 ### 4.4 交互测试(#4) diff --git a/packages/sdk/scripts/package.json b/packages/sdk/scripts/package.json index ba441a528c..6fdbcbc3da 100644 --- a/packages/sdk/scripts/package.json +++ b/packages/sdk/scripts/package.json @@ -32,6 +32,7 @@ "license": "BSD-3-Clause", "dependencies": { "@deepseek-ai/dsh-helper": "workspace:^", + "@deepseek-ai/dsh-telemetry": "workspace:^", "commander": "^15.0.0", "node-addon-require-builtin": "^0.1.0" }, diff --git a/packages/sdk/scripts/src/command.ts b/packages/sdk/scripts/src/command.ts index 5174ec5ded..ebf9b06846 100644 --- a/packages/sdk/scripts/src/command.ts +++ b/packages/sdk/scripts/src/command.ts @@ -9,6 +9,7 @@ import { runProjectBuild } from './build.ts' import { runConfigCommand, type ConfigCommandContext } from './config.ts' import { runCreatePluginCommand } from './create-plugin.ts' import { runSDK } from './runtime.ts' +import { reportCommandTelemetry, type CommandTelemetryEvent } from './telemetry.ts' import { DSH_SDK_TEMPLATES } from './templates/dsh-sdk-templates.ts' /** Injectable process and command boundaries used by the dsh-sdk bin. */ @@ -21,6 +22,7 @@ export interface DshSdkCommandContext extends ConfigCommandContext { build?: typeof runProjectBuild config?: typeof runConfigCommand createPlugin?: typeof runCreatePluginCommand + telemetry?: (event: CommandTelemetryEvent) => Promise } /** Run one parsed dsh-sdk command and return its process exit code. */ @@ -33,12 +35,16 @@ export async function runDshSdkCommand( stderr: process.stderr, }, ): Promise { + const startedAt = Date.now() + let command: string | undefined + let success = true try { const args = parseDshSdkArgs(argv) if (args.help || !args.command) { context.stdout.write(DSH_SDK_TEMPLATES.usage.render({})) return 0 } + command = args.command const run = context.run ?? runSDK const build = context.build ?? runProjectBuild const config = context.config ?? runConfigCommand @@ -49,7 +55,7 @@ export async function runDshSdkCommand( case 'build': await build(args.forwarded, context.cwd); break case 'config': { const result = await config(context) - if (result.installError) return 1 + if (result.installError) { success = false; return 1 } break } /* v8 ignore next -- Commander requires , so create never dispatches without it */ @@ -57,7 +63,14 @@ export async function runDshSdkCommand( } return 0 } catch (error) { + success = false context.stderr.write(`dsh-sdk: ${error instanceof Error ? error.message : String(error)}\n`) return 1 + } finally { + if (command !== undefined) { + /* v8 ignore next -- production telemetry wiring is exercised by the built-bin smoke */ + const telemetry = context.telemetry ?? reportCommandTelemetry + await telemetry({ command, cwd: context.cwd, durationMs: Date.now() - startedAt, success }) + } } } diff --git a/packages/sdk/scripts/src/telemetry.ts b/packages/sdk/scripts/src/telemetry.ts new file mode 100644 index 0000000000..1ef74fdbb7 --- /dev/null +++ b/packages/sdk/scripts/src/telemetry.ts @@ -0,0 +1,63 @@ +/** + * Launcher-side telemetry wiring: resolve consent and send one fire-and-forget + * event around each dsh-sdk command. Best-effort — never affects the command's + * outcome or exit code. + * + * @module @deepseek-ai/dsh-scripts/telemetry + */ + +import { + ConsentResolver, + TelemetryReporter, + buildTelemetryPayload, + type ConsentDecision, +} from '@deepseek-ai/dsh-telemetry' + +/** One command's telemetry lifecycle facts. */ +export interface CommandTelemetryEvent { + /** The dsh-sdk command that ran. */ + command: string + /** Project directory whose consent, `cordis.yml`, and `package.json` are read. */ + cwd: string + /** Wall-clock duration in milliseconds. */ + durationMs: number + /** Whether the command completed without error. */ + success: boolean +} + +/** Injectable consent and delivery seams for tests. */ +export interface CommandTelemetryDeps { + resolve?: (cwd: string) => Promise + reporter?: Pick +} + +/** + * Resolve consent for the project 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. + * @param deps - consent and delivery seams; defaults hit the real endpoint. + */ +export async function reportCommandTelemetry( + event: CommandTelemetryEvent, + deps: CommandTelemetryDeps = {}, +): Promise { + 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) + if (!consent.allowed) return + const payload = await buildTelemetryPayload({ + command: event.command, + durationMs: event.durationMs, + success: event.success, + projectDir: event.cwd, + }) + /* v8 ignore next -- the production TelemetryReporter is exercised by the built-bin smoke */ + const reporter = deps.reporter ?? new TelemetryReporter() + reporter.report(payload, consent) + await reporter.flush() + } catch { + // Telemetry is best-effort; a consent, payload, or delivery fault never reaches the command. + } +} diff --git a/packages/sdk/scripts/tests/scripts.spec.ts b/packages/sdk/scripts/tests/scripts.spec.ts index faa025926e..3ab59f13d8 100644 --- a/packages/sdk/scripts/tests/scripts.spec.ts +++ b/packages/sdk/scripts/tests/scripts.spec.ts @@ -32,6 +32,7 @@ import { runDshSdkCommand, type DshSdkCommandContext } from '../src/command.ts' import { runConfigCommand } from '../src/config.ts' import { ConfigWorkflow, type ConfigPlan } from '../src/config/config-workflow.ts' import { runCreatePluginCommand } from '../src/create-plugin.ts' +import { reportCommandTelemetry, type CommandTelemetryEvent } from '../src/telemetry.ts' import { initialize, resolve as resolveLocalPlugin } from '../src/local-plugin-loader-hooks.ts' const temporary: string[] = [] @@ -619,3 +620,49 @@ describe('dsh-sdk create', () => { await expect(runDshSdkCommand(['create', 'pkg@1.0.0'], context)).resolves.toBe(0) }) }) + +describe('command telemetry', () => { + it('reports when consent allows and skips when denied or faulting', async () => { + const dir = await mkdtemp(join(tmpdir(), 'dsh-telemetry-')) + temporary.push(dir) + const sent: unknown[] = [] + 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 }, + ) + expect(sent).toHaveLength(1) + await reportCommandTelemetry( + { command: 'build', cwd: dir, durationMs: 5, success: true }, + { resolve: async () => ({ allowed: false, reason: 'disabled' }), reporter }, + ) + expect(sent).toHaveLength(1) + await expect(reportCommandTelemetry( + { command: 'build', cwd: dir, durationMs: 5, success: true }, + { resolve: async () => { throw new Error('boom') }, reporter }, + )).resolves.toBeUndefined() + expect(sent).toHaveLength(1) + }) + + it('emits a telemetry event carrying each command outcome', async () => { + const project = await committedProject() + const events: CommandTelemetryEvent[] = [] + const context = commandContext(project.root) + context.telemetry = async (event) => { events.push(event) } + context.build = async () => {} + await expect(runDshSdkCommand(['build'], context)).resolves.toBe(0) + expect(events).toHaveLength(1) + expect(events[0]).toMatchObject({ command: 'build', cwd: project.root, success: true }) + + await runDshSdkCommand([], context) + expect(events).toHaveLength(1) + + context.build = async () => { throw new Error('boom') } + await expect(runDshSdkCommand(['build'], context)).resolves.toBe(1) + expect(events[1]).toMatchObject({ command: 'build', success: false }) + + context.config = async () => ({ installError: new Error('offline') }) + await expect(runDshSdkCommand(['config'], context)).resolves.toBe(1) + expect(events.at(-1)).toMatchObject({ command: 'config', success: false }) + }) +}) diff --git a/packages/sdk/scripts/tsconfig.json b/packages/sdk/scripts/tsconfig.json index 848de9a314..461c86c06d 100644 --- a/packages/sdk/scripts/tsconfig.json +++ b/packages/sdk/scripts/tsconfig.json @@ -7,6 +7,7 @@ "include": ["src"], "references": [ { "path": "../helper" }, + { "path": "../telemetry" }, { "path": "../../ui/app-boot" }, { "path": "../../../vendor/cordis" } ] diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cafebfaae6..68ee8e2640 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1228,6 +1228,9 @@ importers: '@deepseek-ai/dsh-helper': specifier: workspace:^ version: link:../helper + '@deepseek-ai/dsh-telemetry': + specifier: workspace:^ + version: link:../telemetry commander: specifier: ^15.0.0 version: 15.0.0