diff --git a/docs/reference/telemetry-availability.md b/docs/reference/telemetry-availability.md index cf4fc5522..b8c531e8c 100644 --- a/docs/reference/telemetry-availability.md +++ b/docs/reference/telemetry-availability.md @@ -327,6 +327,35 @@ Dashboard caveats: - Use `add_repo_existing_workspaces_detected` to estimate how often added projects had non-main existing workspaces, but do not infer the user selected "use existing worktrees" because that choice no longer exists in the normal flow. - Use `add_repo_default_checkout_handoff` for the current handoff outcome. `result = 'opened_default_checkout'` is the expected path; `result = 'revealed_project'` is the graceful fallback. Break down fallback rows by `source` and `reason`. +### 2026-06-10 - Repo Added Git-vs-Folder Signal + +Scope: `repo_added.is_git_repo` replaces the retired `onboarding_completed.is_git_repo` split for git-vs-folder analysis. Project selection moved out of onboarding in the 1.4.46 flow, so `onboarding_completed` now fires before any repo is chosen. After that boundary, the old `onboarding_completed.is_git_repo` value is not a valid git-vs-folder signal. + +`repo_added.is_git_repo` is sourced from git detection at the add point. It is optional so SSH/remote paths that genuinely cannot determine git-ness can omit the property instead of defaulting to `false`. + +| Field | Value | +| ------------------------ | ------------------------------------------------------------------------------------------- | +| PR | `#5121` | +| Merge commit | `TBD` | +| `code_merged_at_utc` | `TBD` | +| First release | `TBD` | +| First release commit | `TBD` | +| `first_released_at_utc` | `TBD` | +| `first_seen_at_utc` | `TBD` on `repo_added.is_git_repo` | +| `dashboard_ready_at_utc` | `TBD`; use only after first-seen rows exist and field coverage has been checked in PostHog. | + +PostHog evidence checked at `2026-06-10T19:00:00Z`: + +- Dashboard tile "Fresh-install onboarding completion over time" (`JlIt5J1N`, insight id `9076383`, project `406068`) showed the git-repo share collapse to about 4% while plain-folder completions spiked to about 88% on 2026-06-05. +- Raw `onboarding_completed.is_git_repo` counts by `app_version` showed a version cliff: versions through `1.4.45` were about 80% true, while `1.4.46`, `1.4.47`, and `1.4.48` had zero true rows in the sampled data. + +Dashboard caveats: + +- Treat `onboarding_completed.is_git_repo` as historical only after app version `1.4.45`. +- Do not stitch historical `onboarding_completed.is_git_repo` and new `repo_added.is_git_repo` series without an explicit version boundary and label change; they are emitted at different funnel moments. +- Repoint dashboard tile `JlIt5J1N` to use `repo_added.is_git_repo` once the new field is observed in release telemetry. +- Omitted `repo_added.is_git_repo` means unknown/degraded detection, not plain folder. Only explicit `false` means plain folder. + ## Updating This File When adding or changing telemetry that dashboard authors will depend on: diff --git a/src/main/ipc/repos.ts b/src/main/ipc/repos.ts index 93ddbb1eb..7adcb8c06 100644 --- a/src/main/ipc/repos.ts +++ b/src/main/ipc/repos.ts @@ -68,7 +68,14 @@ import { detectRepoIconAndUpstream } from '../repo-icon-autodetect' // `folder_picker` because the user's entry was the folder picker, even // though main also `git init`s. `drag_drop` is reserved for a future call // site; no current renderer surface produces it. -function emitRepoAdded(method: RepoMethod, alreadyExisted: boolean): void { +// +// Why `isGitRepo`: low-cardinality, non-identifying git-vs-folder signal. +// Callers pass it because they already have the git-detection result in scope +// (avoids re-running git I/O here). Pass `undefined` when a call site genuinely +// can't determine git-ness (e.g. some SSH/remote edges) — never default-guess +// `false`. This replaced the now-removed `onboarding_completed.is_git_repo`, +// which became meaningless once repo selection left onboarding (1.4.46). +function emitRepoAdded(method: RepoMethod, alreadyExisted: boolean, isGitRepo?: boolean): void { // Why: re-adding an existing repo (matched by path inside the handler) // is not a new activation event. Suppressing the duplicate keeps the // funnel honest and avoids inflating `repo_added` for users who @@ -80,7 +87,12 @@ function emitRepoAdded(method: RepoMethod, alreadyExisted: boolean): void { // repo is counted — every call site below already emits post-addRepo, so // `getCohortAtEmit()` here returns the user's Nth `repo_added` as `N`. // See docs/onboarding-funnel-cohort-addendum.md §Read-vs-write ordering. - track('repo_added', { method, ...getCohortAtEmit() }) + const props = { + method, + ...(isGitRepo === undefined ? {} : { is_git_repo: isGitRepo }), + ...getCohortAtEmit() + } + track('repo_added', props) } function getRemoteRepoFolderName(remotePath: string): string { @@ -635,7 +647,9 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v }) } results.push({ path: repoPath, projectId: repo.id, status: 'imported' }) - emitRepoAdded('folder_picker', false) + // Why: nested-repo import only reaches here after the isGitRepo / + // isGitRepoAsync guard above confirmed a git repo, so always `true`. + emitRepoAdded('folder_picker', false, true) } catch (error) { results.push({ path: repoPath, @@ -680,7 +694,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v // Check if already added const existing = store.getRepos().find((r) => r.path === args.path) if (existing) { - emitRepoAdded('folder_picker', true) + emitRepoAdded('folder_picker', true, repoKind === 'git') return { repo: existing } } @@ -704,7 +718,9 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v store.addRepo(repo) invalidateAuthorizedRootsCache() notifyReposChanged(mainWindow) - emitRepoAdded('folder_picker', false) + // Why: `repos:add` validates git-ness via `isGitRepo(args.path)` above + // when kind is 'git', and `repoKind` reflects that resolved choice. + emitRepoAdded('folder_picker', false, repoKind === 'git') return { repo } } ) @@ -751,7 +767,10 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v .getRepos() .find((r) => r.connectionId === args.connectionId && r.path === resolvedPath) if (existing) { - emitRepoAdded('folder_picker', true) + // Why: duplicate hit is suppressed by `emitRepoAdded` anyway, and for + // remote adds git-ness isn't resolved until the isGitRepoAsync check + // below — pass `undefined` rather than guess. + emitRepoAdded('folder_picker', true, undefined) return { repo: existing } } @@ -823,7 +842,10 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v mux.notify('session.registerRoot', { rootPath: resolvedPath }) } - emitRepoAdded('folder_picker', false) + // Why: `repoKind` here reflects the SSH/remote-aware isGitRepoAsync + // result resolved above (or an explicit 'folder' kind), so it's the real + // git-vs-folder signal for this remote add. + emitRepoAdded('folder_picker', false, repoKind === 'git') return { repo } } ) @@ -870,7 +892,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v // the race matters even after this one passes. const existing = store.getRepos().find((r) => r.path === targetPath) if (existing) { - emitRepoAdded('folder_picker', true) + emitRepoAdded('folder_picker', true, repoKind === 'git') return { repo: existing } } @@ -997,7 +1019,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v // other invocation is using it. Leaking a freshly-made empty folder on // a rare race is strictly safer than deleting a directory the winning // call (and the user) now owns. - emitRepoAdded('folder_picker', true) + emitRepoAdded('folder_picker', true, repoKind === 'git') return { repo: raceWinner } } @@ -1021,7 +1043,9 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v store.addRepo(repo) invalidateAuthorizedRootsCache() notifyReposChanged(mainWindow) - emitRepoAdded('folder_picker', false) + // Why: `repos:create` git-inits when kind is 'git', so `repoKind` is the + // true git-vs-folder signal for the just-created project. + emitRepoAdded('folder_picker', false, repoKind === 'git') return { repo } } ) @@ -1265,7 +1289,8 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v .getRepos() .find((r) => getClonePathComparisonKey(r.path) === clonePathKey) if (existingAfterPendingClone && !isFolderRepo(existingAfterPendingClone)) { - emitRepoAdded('clone_url', true) + // Why: clone_url always produces a git repo. + emitRepoAdded('clone_url', true, true) return existingAfterPendingClone } // Why: gitSpawn uses args.destination as cwd, so it must exist before @@ -1401,11 +1426,11 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v if (updated) { notifyReposChanged(mainWindow) // Why: folder→git upgrade is a real new git repo provisioning event. - emitRepoAdded('clone_url', false) + emitRepoAdded('clone_url', false, true) return updated } } - emitRepoAdded('clone_url', true) + emitRepoAdded('clone_url', true, true) return existing } @@ -1425,7 +1450,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v store.addRepo(repo) invalidateAuthorizedRootsCache() notifyReposChanged(mainWindow) - emitRepoAdded('clone_url', false) + emitRepoAdded('clone_url', false, true) return repo } finally { const metadata = cloneMetadataRef.current diff --git a/src/main/telemetry/validator.test.ts b/src/main/telemetry/validator.test.ts index d8b1d6d5b..bb93bdad1 100644 --- a/src/main/telemetry/validator.test.ts +++ b/src/main/telemetry/validator.test.ts @@ -128,6 +128,53 @@ describe('validate', () => { expect(result.ok).toBe(true) }) + // ── repo_added.is_git_repo (docs/reference/telemetry-availability.md) + // The git-vs-folder signal moved here from onboarding_completed once project + // selection left onboarding. Optional so SSH/remote edges can omit it. + + it('accepts repo_added with is_git_repo=true', () => { + const result = validate('repo_added', { method: 'clone_url', is_git_repo: true }) + expect(result.ok).toBe(true) + }) + + it('accepts repo_added with is_git_repo=false', () => { + const result = validate('repo_added', { method: 'folder_picker', is_git_repo: false }) + expect(result.ok).toBe(true) + }) + + it('accepts repo_added without is_git_repo (SSH/remote degraded mode)', () => { + const result = validate('repo_added', { method: 'folder_picker' }) + expect(result.ok).toBe(true) + }) + + it('rejects non-boolean is_git_repo on repo_added', () => { + const result = validate('repo_added', { + method: 'folder_picker', + is_git_repo: 'yes' + } as never) + expect(result.ok).toBe(false) + }) + + it('rejects the retired is_git_repo field on onboarding_completed', () => { + // Why: the field moved to repo_added; onboarding_completed is .strict() so + // the vestigial key must now drop. Guards against a stale call site + // re-adding the meaningless always-false signal. + const result = validate('onboarding_completed', { + path: 'add_project_modal', + is_git_repo: false, + total_duration_ms: 100 + } as never) + expect(result.ok).toBe(false) + }) + + it('accepts onboarding_completed without is_git_repo', () => { + const result = validate('onboarding_completed', { + path: 'add_project_modal', + total_duration_ms: 100 + }) + expect(result.ok).toBe(true) + }) + it('accepts events without nth_repo_added (classifier degraded mode)', () => { const result = validate('agent_started', { agent_kind: 'claude-code', diff --git a/src/renderer/src/components/onboarding/use-onboarding-flow-persistence.ts b/src/renderer/src/components/onboarding/use-onboarding-flow-persistence.ts index 9b39bd7d7..1c0b226fd 100644 --- a/src/renderer/src/components/onboarding/use-onboarding-flow-persistence.ts +++ b/src/renderer/src/components/onboarding/use-onboarding-flow-persistence.ts @@ -91,9 +91,11 @@ export function useCloseWith({ onOnboardingChange(nextState) if (outcome === 'completed' && completedPath) { const total = Math.max(0, Date.now() - startTimeRef.current) + // Why: no `is_git_repo` — project selection now happens in the Add + // Project modal after this fires, so the signal moved to + // `repo_added.is_git_repo`. See docs/reference/telemetry-availability.md. track('onboarding_completed', { path: completedPath, - is_git_repo: checklist.addedRepo === true, total_duration_ms: total }) // Why: checklist items completed by the wizard itself must fire diff --git a/src/shared/telemetry-events.ts b/src/shared/telemetry-events.ts index fdbbd3d16..bff2794fb 100644 --- a/src/shared/telemetry-events.ts +++ b/src/shared/telemetry-events.ts @@ -302,7 +302,17 @@ const nthRepoAddedSchema = z.number().int().nonnegative().optional() const appOpenedSchema = z.object({ nth_repo_added: nthRepoAddedSchema }).strict() const repoAddedSchema = z - .object({ method: repoMethodSchema, nth_repo_added: nthRepoAddedSchema }) + // Why: `is_git_repo` is the real git-vs-folder signal, sourced from git + // detection at the add point. It moved here from `onboarding_completed` + // once project selection left onboarding (1.4.46). `.optional()` so + // SSH/remote or any path that genuinely can't determine git-ness validates + // cleanly instead of crashing the track call — same fail-soft intent as + // `nthRepoAddedSchema`. Never default-guess `false`; omit instead. + .object({ + method: repoMethodSchema, + is_git_repo: z.boolean().optional(), + nth_repo_added: nthRepoAddedSchema + }) .strict() const appStarredOrcaSchema = z @@ -939,10 +949,12 @@ const onboardingTaskSourcesSnapshotSchema = z cohort: cohortSchema }) .strict() +// Why: no `is_git_repo` here — the signal moved to `repo_added.is_git_repo`. +// Project selection left onboarding in 1.4.46, so this event now fires before +// any repo is chosen; the old field was always `false` and meaningless. const onboardingCompletedSchema = z .object({ path: onboardingPathSchema, - is_git_repo: z.boolean(), total_duration_ms: z.number().int().nonnegative(), cohort: cohortSchema })