mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
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:
parent
72c8e34bc9
commit
c30f3fbfac
4 changed files with 128 additions and 23 deletions
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue