fix(telemetry): report unless telemetry entry is explicitly disabled

Per ccyu's decision (option A), remove the consent asymmetry in ConsentResolver:
telemetry is OFF only when cordis.yml has a telemetry entry with disabled: true.
A cordis.yml with no telemetry entry now reports (allowWhenEntryAbsent defaults
to true) rather than denying. No cordis.yml, an enabled entry, and DO_NOT_TRACK/CI
are unchanged. Both no-config and absent-entry defaults stay configurable.

Updates the module/README docs and the unit tests (file-present-but-no-entry and
non-object/non-sequence roots now report). Per-file 100% coverage holds.
This commit is contained in:
imccyu
2026-07-17 14:03:39 +08:00
parent fe668a6dfe
commit a66b98d270
3 changed files with 17 additions and 17 deletions

View File

@@ -10,7 +10,7 @@ Launcher-side telemetry primitives for the dsh-sdk toolchain. This is a plain li
| `getOrCreateAnonymousId` | Random UUID persisted in a per-user GLOBAL config file (never in the project, never derived from git). | | `getOrCreateAnonymousId` | Random UUID persisted in a per-user GLOBAL config file (never in the project, never derived from git). |
| `TelemetryReporter` | Fire-and-forget send: `report()` never blocks or throws; delivery resolves on every path; `flush()` optionally drains in-flight sends within a cap. | | `TelemetryReporter` | Fire-and-forget send: `report()` never blocks or throws; delivery resolves on every path; `flush()` optionally drains in-flight sends within a cap. |
Consent is carried by the telemetry entry in `cordis.yml`, so disabling telemetry is disabling that entry. When `cordis.yml` does not yet exist (first `create`) consent defaults to allowed; when it exists without a telemetry entry consent defaults to denied — both are configurable on `ConsentResolver`. Consent is carried by the telemetry entry in `cordis.yml`, so disabling telemetry is disabling that entry. Telemetry reports by default and is off only when a present telemetry entry is explicitly `disabled`: a missing `cordis.yml` (first `create`), an enabled entry, or a `cordis.yml` with no telemetry entry all report. `DO_NOT_TRACK`/CI always deny. The no-config and absent-entry defaults are configurable on `ConsentResolver`.
The collection endpoint is a fixed constant (`DSH_TELEMETRY_ENDPOINT`); its `.invalid` placeholder must be replaced with the real endpoint before release. The collection endpoint is a fixed constant (`DSH_TELEMETRY_ENDPOINT`); its `.invalid` placeholder must be replaced with the real endpoint before release.

View File

@@ -1,12 +1,12 @@
/** /**
* Consent resolution for dsh-sdk telemetry. * Consent resolution for dsh-sdk telemetry.
* *
* Consent is carried by the telemetry plugin's enabled/disabled state in the * Telemetry is OFF only when `cordis.yml` contains a telemetry entry that is
* project `cordis.yml`: an enabled entry means opt-in, a `disabled: true` entry * explicitly `disabled`; every other file state reports (no `cordis.yml`, an
* means opt-out. The resolver PARSES `cordis.yml` — it never boots a Cordis * enabled entry, or no telemetry entry at all). The resolver PARSES `cordis.yml`
* application — because several launcher commands (`build`, `create`) never * — it never boots a Cordis application — because several launcher commands
* boot Cordis at all. `DO_NOT_TRACK` and CI environment signals force a denial * (`build`, `create`) never boot Cordis at all. `DO_NOT_TRACK` and CI
* regardless of file state. * environment signals force a denial regardless of file state.
* *
* @module @deepseek-ai/dsh-telemetry/consent-resolver * @module @deepseek-ai/dsh-telemetry/consent-resolver
*/ */
@@ -56,7 +56,7 @@ export interface ConsentResolverOptions {
honorEnvOptOut?: boolean honorEnvOptOut?: boolean
/** Consent when `cordis.yml` does not exist yet (first `create`). Defaults to `true` (telemetry is default-on). */ /** Consent when `cordis.yml` does not exist yet (first `create`). Defaults to `true` (telemetry is default-on). */
allowWhenNoConfig?: boolean allowWhenNoConfig?: boolean
/** Consent when `cordis.yml` exists but has no telemetry entry. Defaults to `false`. */ /** Consent when `cordis.yml` exists but has no telemetry entry. Defaults to `true` (report unless a present entry is disabled). */
allowWhenEntryAbsent?: boolean allowWhenEntryAbsent?: boolean
} }
@@ -94,7 +94,7 @@ export class ConsentResolver {
this.#env = options.env ?? process.env this.#env = options.env ?? process.env
this.#honorEnvOptOut = options.honorEnvOptOut ?? true this.#honorEnvOptOut = options.honorEnvOptOut ?? true
this.#allowWhenNoConfig = options.allowWhenNoConfig ?? true this.#allowWhenNoConfig = options.allowWhenNoConfig ?? true
this.#allowWhenEntryAbsent = options.allowWhenEntryAbsent ?? false this.#allowWhenEntryAbsent = options.allowWhenEntryAbsent ?? true
} }
/** /**

View File

@@ -84,23 +84,23 @@ describe('ConsentResolver cordis.yml state', () => {
.toEqual<ConsentDecision>({ allowed: true, reason: 'enabled' }) .toEqual<ConsentDecision>({ allowed: true, reason: 'enabled' })
}) })
it('reports absent when cordis.yml has no telemetry entry, defaulting to deny', async () => { it('reports (allows) when cordis.yml has no telemetry entry', async () => {
const yml = '- id: llm\n name: \'@deepseek-ai/dsh-llm-deepseek\'\n' const yml = '- id: llm\n name: \'@deepseek-ai/dsh-llm-deepseek\'\n'
expect(await resolver.resolve(await projectDir(yml))) expect(await resolver.resolve(await projectDir(yml)))
.toEqual<ConsentDecision>({ allowed: false, reason: 'absent' }) .toEqual<ConsentDecision>({ allowed: true, reason: 'absent' })
}) })
it('can allow when the entry is absent', async () => { it('can be told to deny when the entry is absent', async () => {
const yml = '- id: llm\n name: \'@deepseek-ai/dsh-llm-deepseek\'\n' const yml = '- id: llm\n name: \'@deepseek-ai/dsh-llm-deepseek\'\n'
const decision = await new ConsentResolver({ env: {}, allowWhenEntryAbsent: true }).resolve(await projectDir(yml)) const decision = await new ConsentResolver({ env: {}, allowWhenEntryAbsent: false }).resolve(await projectDir(yml))
expect(decision).toEqual<ConsentDecision>({ allowed: true, reason: 'absent' }) expect(decision).toEqual<ConsentDecision>({ allowed: false, reason: 'absent' })
}) })
it('skips non-object sequence items and a non-sequence root', async () => { it('skips non-object sequence items and a non-sequence root, still reporting absent', async () => {
expect(await resolver.resolve(await projectDir('- just-a-string\n- id: x\n name: y\n'))) expect(await resolver.resolve(await projectDir('- just-a-string\n- id: x\n name: y\n')))
.toEqual<ConsentDecision>({ allowed: false, reason: 'absent' }) .toEqual<ConsentDecision>({ allowed: true, reason: 'absent' })
expect(await resolver.resolve(await projectDir('root: not-a-sequence\n'))) expect(await resolver.resolve(await projectDir('root: not-a-sequence\n')))
.toEqual<ConsentDecision>({ allowed: false, reason: 'absent' }) .toEqual<ConsentDecision>({ allowed: true, reason: 'absent' })
}) })
it('honors a custom telemetry plugin name', async () => { it('honors a custom telemetry plugin name', async () => {