Fix git repo telemetry for repo adds (#5121)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Brennan Benson 2026-06-10 13:24:49 -07:00 committed by GitHub
parent ceae167427
commit 2fa2cc8cb1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 132 additions and 17 deletions

View File

@ -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:

View File

@ -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

View File

@ -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',

View File

@ -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

View File

@ -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
})