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:
imccyu
2026-07-17 20:28:31 +08:00
parent ca7533880e
commit 560ce3b539
7 changed files with 132 additions and 1 deletions
+14 -1
View File
@@ -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
View 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.
}
}