mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
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).
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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<void>
|
||||
}
|
||||
|
||||
/** Run one parsed dsh-sdk command and return its process exit code. */
|
||||
@@ -33,12 +35,16 @@ export async function runDshSdkCommand(
|
||||
stderr: process.stderr,
|
||||
},
|
||||
): Promise<number> {
|
||||
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 <source>, 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 })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
63
packages/sdk/scripts/src/telemetry.ts
Normal file
63
packages/sdk/scripts/src/telemetry.ts
Normal file
@@ -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<ConsentDecision>
|
||||
reporter?: Pick<TelemetryReporter, 'report' | 'flush'>
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<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)
|
||||
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.
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
"include": ["src"],
|
||||
"references": [
|
||||
{ "path": "../helper" },
|
||||
{ "path": "../telemetry" },
|
||||
{ "path": "../../ui/app-boot" },
|
||||
{ "path": "../../../vendor/cordis" }
|
||||
]
|
||||
|
||||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user