feat(analytics): default telemetry to on so onboarding events emit pre-disclosure (#2682)

The post-onboarding disclosure modal (App.tsx:349 — `showPrivacyConsent =
... && config.onboardingCompleted === true`) only renders after the user
completes the welcome flow. Before that, `config.telemetry` is undefined
and both the daemon-side gate
(`analytics.ts: if (cfg.telemetry?.metrics !== true) return`) and the
web-side `/api/analytics/config` drop every event the onboarding view
fires.

E2E on nightly.10 (QA, 2026-05-22 06:15+ UTC) confirmed the symptom: a
real user completed the full Connect → About you → Design system → Generate
flow but PostHog received zero `page_view pn=onboarding` / `ui_click` /
`onboarding_runtime_scan_result` / `onboarding_complete_result` rows from
their distinct_id. Other events (post-onboarding home, settings, project
creation) flowed normally because by then the disclosure had been
accepted and `telemetry.metrics` was true.

Product decision (2026-05-22): default telemetry ON. The disclosure
modal stays disclosure-style ("I get it") and Settings → Privacy
remains the one-click opt-out — same UX, only the pre-decision default
changes from off to on.

Changes:
- `apps/web/src/state/config.ts`: `DEFAULT_CONFIG.telemetry =
  { metrics: true, content: true, artifactManifest: false }` so fresh
  `loadConfig()` calls emit during the first onboarding render.
- `apps/web/src/types.ts`: comment now documents the default-on
  semantics + the opt-out path.
- `apps/daemon/src/app-config.ts`: new `applyTelemetryDefaults` helper
  fills in the same defaults when `readAppConfig` finds no telemetry
  field on disk. Helper runs on BOTH the
  installation-file-shadowing path and the fallback path. An
  explicit user opt-out (`metrics: false`) is preserved untouched —
  defaults only fill `undefined`, never overwrite a saved value.
- `apps/daemon/tests/app-config.test.ts`: 49 → 51 tests. Updated 9
  existing assertions that expected `cfg.telemetry === undefined` /
  `cfg === {}` to expect the new default; added 2 regression guards:
  - "preserves an explicit telemetry opt-out across reads" pins the
    `metrics: false` invariant so a future refactor can't silently
    re-enable opted-out users.
  - "preserves a partial explicit telemetry (metrics on, content off)"
    pins per-field user choices against the default fill.

Validation:
- `pnpm --filter @open-design/daemon exec vitest run tests/app-config.test.ts`  51/51
- `pnpm --filter @open-design/web typecheck` 
- `pnpm --filter @open-design/daemon typecheck` 
- `pnpm --filter @open-design/web test`  201 files / 1839 tests
This commit is contained in:
lefarcen 2026-05-22 15:42:20 +08:00 committed by GitHub
parent 72c8e34bc9
commit c30f3fbfac
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 128 additions and 23 deletions

View file

@ -332,6 +332,26 @@ function filterAllowedKeys(obj: Record<string, unknown>): AppConfigPrefs {
return result as AppConfigPrefs;
}
// Fill in telemetry defaults when the saved config has no `telemetry`
// field at all (fresh install, pre-disclosure). `metrics` / `content`
// default to true so onboarding-funnel events emit from the first
// render — without these defaults the gate at
// `analytics.ts` (`if (cfg.telemetry?.metrics !== true) return`)
// dropped every event a user fired before the post-onboarding
// disclosure modal had a chance to set them. An EXPLICIT `false`
// the user previously saved is preserved (only `undefined` gets
// the new default), so opt-out users stay opted out across the
// 0.7.x → 0.8.0 upgrade.
function applyTelemetryDefaults(prefs: AppConfigPrefs): AppConfigPrefs {
if (prefs.telemetry === undefined) {
return {
...prefs,
telemetry: { metrics: true, content: true, artifactManifest: false },
};
}
return prefs;
}
export async function readAppConfig(dataDir: string): Promise<AppConfigPrefs> {
const base = await readAppConfigFileOnly(dataDir);
// Channel-root installation file is the new authoritative source for the
@ -348,7 +368,7 @@ export async function readAppConfig(dataDir: string): Promise<AppConfigPrefs> {
const installationDir = resolveInstallationDir(dataDir);
const installation = await readInstallationFile(installationDir);
if (typeof installation.installationId === 'string' && installation.installationId.length > 0) {
return { ...base, installationId: installation.installationId };
return applyTelemetryDefaults({ ...base, installationId: installation.installationId });
}
if (typeof base.installationId === 'string' && base.installationId.length > 0) {
// Best-effort migration. A write failure here doesn't break the read —
@ -360,7 +380,7 @@ export async function readAppConfig(dataDir: string): Promise<AppConfigPrefs> {
// swallow — observability beats correctness on this path
}
}
return base;
return applyTelemetryDefaults(base);
}
async function readAppConfigFileOnly(dataDir: string): Promise<AppConfigPrefs> {

View file

@ -16,6 +16,18 @@ import {
import { readAppConfig, writeAppConfig } from '../src/app-config.js';
import { isLocalSameOrigin } from '../src/origin-validation.js';
// Default telemetry preference applied when an existing config has no
// telemetry block (fresh install, pre-disclosure). See
// `app-config.ts#applyTelemetryDefaults` and `state/config.ts#DEFAULT_CONFIG`
// for the matching client default. Tests that previously expected an
// empty `{}` are now updated to expect this default; tests confirming
// "user opted out → stays opted out" assert on `metrics: false`.
const DEFAULT_TELEMETRY = {
metrics: true,
content: true,
artifactManifest: false,
} as const;
describe('app-config', () => {
let dataDir: string;
@ -28,35 +40,38 @@ describe('app-config', () => {
});
describe('readAppConfig', () => {
it('returns {} when config file does not exist', async () => {
expect(await readAppConfig(dataDir)).toEqual({});
it('returns default telemetry when config file does not exist', async () => {
expect(await readAppConfig(dataDir)).toEqual({
telemetry: DEFAULT_TELEMETRY,
});
});
it('returns parsed config from existing file', async () => {
it('returns parsed config from existing file (with default telemetry)', async () => {
await writeFile(
path.join(dataDir, 'app-config.json'),
JSON.stringify({ onboardingCompleted: true }),
);
const cfg = await readAppConfig(dataDir);
expect(cfg.onboardingCompleted).toBe(true);
expect(cfg.telemetry).toEqual(DEFAULT_TELEMETRY);
});
it('returns {} for corrupted JSON without crashing', async () => {
it('returns default telemetry for corrupted JSON without crashing', async () => {
await writeFile(path.join(dataDir, 'app-config.json'), '{not valid');
const cfg = await readAppConfig(dataDir);
expect(cfg).toEqual({});
expect(cfg).toEqual({ telemetry: DEFAULT_TELEMETRY });
});
it('returns {} when file contains a JSON array', async () => {
it('returns default telemetry when file contains a JSON array', async () => {
await writeFile(path.join(dataDir, 'app-config.json'), '[1,2,3]');
const cfg = await readAppConfig(dataDir);
expect(cfg).toEqual({});
expect(cfg).toEqual({ telemetry: DEFAULT_TELEMETRY });
});
it('returns {} when file contains a JSON primitive', async () => {
it('returns default telemetry when file contains a JSON primitive', async () => {
await writeFile(path.join(dataDir, 'app-config.json'), '"hello"');
const cfg = await readAppConfig(dataDir);
expect(cfg).toEqual({});
expect(cfg).toEqual({ telemetry: DEFAULT_TELEMETRY });
});
it('filters out unknown keys from stored file', async () => {
@ -65,7 +80,7 @@ describe('app-config', () => {
JSON.stringify({ agentId: 'claude', rogue: 'value', __proto: 'x' }),
);
const cfg = await readAppConfig(dataDir);
expect(cfg).toEqual({ agentId: 'claude' });
expect(cfg).toEqual({ agentId: 'claude', telemetry: DEFAULT_TELEMETRY });
expect(cfg).not.toHaveProperty('rogue');
expect(cfg).not.toHaveProperty('__proto');
});
@ -81,7 +96,39 @@ describe('app-config', () => {
}),
);
const cfg = await readAppConfig(dataDir);
expect(cfg).toEqual({});
expect(cfg).toEqual({ telemetry: DEFAULT_TELEMETRY });
});
it('preserves an explicit telemetry opt-out across reads', async () => {
// Regression guard: the `applyTelemetryDefaults` helper must only
// fill in defaults when the saved config has NO telemetry field.
// A user who explicitly opted out (toggled metrics off in
// Settings → Privacy) keeps `metrics: false`; we never silently
// re-enable it on read.
await writeFile(
path.join(dataDir, 'app-config.json'),
JSON.stringify({
telemetry: { metrics: false, content: false, artifactManifest: false },
}),
);
const cfg = await readAppConfig(dataDir);
expect(cfg.telemetry).toEqual({
metrics: false,
content: false,
artifactManifest: false,
});
});
it('preserves a partial explicit telemetry (metrics on, content off)', async () => {
// The user picked a non-default combo (e.g. metrics on for funnel,
// content off for privacy). We hand back exactly what they saved
// — defaults never overwrite explicit per-field choices.
await writeFile(
path.join(dataDir, 'app-config.json'),
JSON.stringify({ telemetry: { metrics: true, content: false } }),
);
const cfg = await readAppConfig(dataDir);
expect(cfg.telemetry).toEqual({ metrics: true, content: false });
});
it('preserves omitted orbit.templateSkillId from legacy stored config', async () => {
@ -177,7 +224,11 @@ describe('app-config', () => {
agentId: 'claude',
});
const cfg = await readAppConfig(dataDir);
expect(cfg).toEqual({ onboardingCompleted: true, agentId: 'claude' });
expect(cfg).toEqual({
onboardingCompleted: true,
agentId: 'claude',
telemetry: DEFAULT_TELEMETRY,
});
expect(cfg).not.toHaveProperty('unknownKey');
});
@ -189,7 +240,7 @@ describe('app-config', () => {
designSystemId: { id: 'bad' },
});
const cfg = await readAppConfig(dataDir);
expect(cfg).toEqual({});
expect(cfg).toEqual({ telemetry: DEFAULT_TELEMETRY });
});
it('merges with existing config', async () => {
@ -492,14 +543,23 @@ describe('app-config telemetry prefs', () => {
expect(cfg.telemetry).toEqual({ artifactManifest: true });
});
it('drops telemetry entirely when no inner key is valid', async () => {
it('drops invalid telemetry entirely from the on-disk file (read backfills the default)', async () => {
// Pre-default era: a bad-shaped `telemetry` write got stripped and
// `readAppConfig` returned `cfg.telemetry === undefined`. After the
// 2026-05-22 default-on switch, the same read backfills the
// default — telemetry is never undefined for callers, but the
// user's invalid value still didn't make it to disk. The
// assertion now tracks "what the gate sees" (the default), since
// that's the actually observable behavior; the write-validation
// invariant the test was guarding is still in force (nothing of
// the bad input survives).
await writeAppConfig(dataDir, {
onboardingCompleted: true,
telemetry: { metrics: 'yes' } as any,
} as any);
const cfg = await readAppConfig(dataDir);
expect(cfg.onboardingCompleted).toBe(true);
expect(cfg.telemetry).toBeUndefined();
expect(cfg.telemetry).toEqual(DEFAULT_TELEMETRY);
});
it('drops unknown keys nested inside telemetry', async () => {
@ -511,19 +571,26 @@ describe('app-config telemetry prefs', () => {
expect(cfg.telemetry).not.toHaveProperty('rogue');
});
it('drops telemetry when value is not a plain object', async () => {
it('drops telemetry when value is not a plain object (read backfills default)', async () => {
await writeAppConfig(dataDir, { telemetry: [true] } as any);
const cfg = await readAppConfig(dataDir);
expect(cfg.telemetry).toBeUndefined();
expect(cfg.telemetry).toEqual(DEFAULT_TELEMETRY);
});
it('clears telemetry when null is sent', async () => {
it('clearing telemetry by sending null resets to default (read backfills)', async () => {
// Sending `null` for telemetry erases the on-disk field. Read
// path then backfills the default because the absence of a value
// is treated the same as a fresh install. If the user really
// wants to opt out, the PrivacySection writes
// `{ metrics: false, content: false, ... }` explicitly — that
// shape persists and is preserved across reads (see "preserves
// an explicit telemetry opt-out across reads" above).
await writeAppConfig(dataDir, {
telemetry: { metrics: true, content: true },
});
await writeAppConfig(dataDir, { telemetry: null } as any);
const cfg = await readAppConfig(dataDir);
expect(cfg.telemetry).toBeUndefined();
expect(cfg.telemetry).toEqual(DEFAULT_TELEMETRY);
});
it('merges telemetry without disturbing other keys', async () => {

View file

@ -82,6 +82,17 @@ export const DEFAULT_CONFIG: AppConfig = {
pet: DEFAULT_PET,
notifications: DEFAULT_NOTIFICATIONS,
orbit: DEFAULT_ORBIT,
// Telemetry defaults to ON so fresh-install users emit onboarding /
// ui_click events from the first frame. The disclosure modal still
// appears after `onboardingCompleted` flips, and Settings → Privacy
// remains the one-click opt-out. Without these defaults the gate at
// `daemon/src/analytics.ts` (`if (telemetry?.metrics !== true) return`)
// dropped every event fired during onboarding because no consent
// existed yet — observed live on the nightly.10 QA run, which left
// zero `page_view pn=onboarding` rows on PostHog despite the user
// completing the flow. `artifactManifest` stays off; the existing
// PrivacySection lets the user enable it explicitly.
telemetry: { metrics: true, content: true, artifactManifest: false },
};
/** Well-known providers with pre-filled base URLs. */

View file

@ -355,8 +355,15 @@ export interface AppConfig {
// rotate or clear the anonymous id without re-opening the consent banner.
privacyDecisionAt?: number | null;
// Privacy preferences governing what (if anything) is shipped to the
// Langfuse-backed telemetry endpoint. All three default to off until the
// user makes an explicit choice.
// PostHog / Langfuse telemetry endpoints. `metrics` and `content`
// default ON (set by `DEFAULT_CONFIG.telemetry` in state/config.ts) so
// the onboarding funnel actually captures the first-run events the
// user hasn't had a chance to consent to yet; the post-onboarding
// disclosure modal explains this and Settings → Privacy is the
// one-click opt-out. `artifactManifest` stays off until the user
// turns it on explicitly. A daemon-stored override always wins over
// these client defaults — once the user picks a value the modal /
// PrivacySection persist it through `syncConfigToDaemon`.
telemetry?: TelemetryConfig;
customInstructions?: string;
}