diff --git a/packages/scaffold/scripts/src/telemetry.ts b/packages/scaffold/scripts/src/telemetry.ts index 37ad56c72a..9533c8e191 100644 --- a/packages/scaffold/scripts/src/telemetry.ts +++ b/packages/scaffold/scripts/src/telemetry.ts @@ -59,15 +59,18 @@ export function freezeTelemetryConsent(env: NodeJS.ProcessEnv = process.env): Co * 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 hooks; defaults hit the real endpoint. + * @param deps - Consent and delivery hooks; defaults hit the real endpoint. Pass + * `consent` frozen from the launching environment before the command ran: + * without it this resolves `process.env` as it stands now, which a project + * `.env` or project code may already have changed. */ export async function reportCommandTelemetry( event: CommandTelemetryEvent, deps: CommandTelemetryDeps = {}, ): Promise { try { - /* v8 ignore next -- the production resolver is exercised by its owning tests */ - const consent = deps.consent ?? await (deps.resolve?.() ?? resolveTelemetryConsent()) + const consent = deps.consent ?? await resolveDeferredConsent(deps) + /* v8 ignore next -- v8 mis-accounts this early return's implicit else; both outcomes are asserted */ if (!consent.allowed) return const payload = await buildTelemetryPayload({ command: event.command, @@ -83,3 +86,14 @@ export async function reportCommandTelemetry( // Telemetry is best-effort; a consent, payload, or delivery fault never reaches the command. } } + +/** + * Resolve consent for a caller that supplied no frozen decision, reading the + * environment as it stands after the command ran. + * @param deps - the caller's consent hooks. + * @returns the resolved consent decision. + */ +async function resolveDeferredConsent(deps: CommandTelemetryDeps): Promise { + /* v8 ignore next -- the production resolver is exercised by its owning tests */ + return await (deps.resolve?.() ?? resolveTelemetryConsent()) +} diff --git a/packages/scaffold/scripts/tests/scripts.spec.ts b/packages/scaffold/scripts/tests/scripts.spec.ts index 396b6b7ee7..50a11cdda9 100644 --- a/packages/scaffold/scripts/tests/scripts.spec.ts +++ b/packages/scaffold/scripts/tests/scripts.spec.ts @@ -647,6 +647,18 @@ describe('command telemetry', () => { ) expect(sent).toHaveLength(0) expect(resolved).toBe(0) + + // A frozen FULL decision reports without consulting the resolver either. + await reportCommandTelemetry( + { command: 'start', cwd: dir, durationMs: 5, success: true }, + { + consent: { allowed: true, reason: 'FULL' }, + resolve: () => { resolved += 1; return { allowed: false, reason: 'DISABLED' } }, + reporter, + }, + ) + expect(sent).toHaveLength(1) + expect(resolved).toBe(0) }) it('freezes consent from the launching environment and denies unsupported modes', () => { diff --git a/packages/scaffold/telemetry/src/consent-resolver.ts b/packages/scaffold/telemetry/src/consent-resolver.ts index 190481b8ed..db2f8c81a4 100644 --- a/packages/scaffold/telemetry/src/consent-resolver.ts +++ b/packages/scaffold/telemetry/src/consent-resolver.ts @@ -24,6 +24,11 @@ export interface ConsentDecision { /** * Resolve launcher telemetry consent from the shared telemetry mode. + * + * Callers that wrap a command must read the launching environment before that + * command runs: a project `.env` load or project code can change + * `process.env`, and resolving afterwards would let the project authorize + * reporting of its own configuration. * @param env - Environment containing `DSH_TELEMETRY_MODE`; defaults to `process.env`. * @returns Whether launcher telemetry may report and the resolved mode. */