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
@@ -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 })
})
})