From a68f0d20f7dc0f924f42fdeea87dda217e5d39ba Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 28 May 2026 13:38:46 -0700 Subject: [PATCH] Track nested repo import telemetry (#3006) Co-authored-by: Orca --- .../components/onboarding/OnboardingFlow.tsx | 2 +- .../components/onboarding/RepoStep.test.tsx | 19 ++ .../onboarding/use-onboarding-flow.ts | 190 +++++++++++++-- .../src/components/sidebar/AddRepoDialog.tsx | 153 +++++++++++- .../src/components/sidebar/AddRepoSteps.tsx | 12 +- .../nested-repo-telemetry-schema.test.ts | 171 +++++++++++++ src/shared/nested-repo-telemetry.test.ts | 221 +++++++++++++++++ src/shared/nested-repo-telemetry.ts | 228 ++++++++++++++++++ src/shared/telemetry-events.ts | 113 +++++++++ 9 files changed, 1081 insertions(+), 28 deletions(-) create mode 100644 src/shared/nested-repo-telemetry-schema.test.ts create mode 100644 src/shared/nested-repo-telemetry.test.ts create mode 100644 src/shared/nested-repo-telemetry.ts diff --git a/src/renderer/src/components/onboarding/OnboardingFlow.tsx b/src/renderer/src/components/onboarding/OnboardingFlow.tsx index 3522bb113b7..09d963530b3 100644 --- a/src/renderer/src/components/onboarding/OnboardingFlow.tsx +++ b/src/renderer/src/components/onboarding/OnboardingFlow.tsx @@ -365,7 +365,7 @@ export default function OnboardingFlow({ busyLabel={busyLabel} onSkipToRepo={() => void flow.skipToRepo()} stepIndex={stepIndex} - onBack={flow.back} + onBack={flow.nestedScan ? flow.cancelNested : flow.back} showPrimary={currentStep.id !== 'repo' || flow.hasExistingProject} primaryBusy={shouldShowFooterBusy} primaryLabel={footerPrimaryLabel} diff --git a/src/renderer/src/components/onboarding/RepoStep.test.tsx b/src/renderer/src/components/onboarding/RepoStep.test.tsx index 45e07f1e6d3..f06c179c064 100644 --- a/src/renderer/src/components/onboarding/RepoStep.test.tsx +++ b/src/renderer/src/components/onboarding/RepoStep.test.tsx @@ -40,4 +40,23 @@ describe('RepoStep', () => { expect(html).toContain('Open a folder') expect(html).toContain('Clone a repo') }) + + it('disables nested import actions when no repositories are selected', () => { + const html = renderRepoStep({ + nestedScan: { + selectedPath: '/workspace/platform', + selectedPathKind: 'non_git_folder', + repos: [{ path: '/workspace/platform/apps/web', displayName: 'web', depth: 2 }], + truncated: false, + timedOut: false, + durationMs: 4, + maxDepth: 3 + }, + nestedGroupName: 'platform' + }) + + expect(html).toContain('Import separately') + expect(html).toContain('Import as project group') + expect(html.match(/disabled=""/g)?.length).toBeGreaterThanOrEqual(2) + }) }) diff --git a/src/renderer/src/components/onboarding/use-onboarding-flow.ts b/src/renderer/src/components/onboarding/use-onboarding-flow.ts index 9b97c0268f7..9220b45cc55 100644 --- a/src/renderer/src/components/onboarding/use-onboarding-flow.ts +++ b/src/renderer/src/components/onboarding/use-onboarding-flow.ts @@ -10,6 +10,14 @@ import { buildAgentPickedPayload } from './agent-picked-payload' import { ONBOARDING_FINAL_STEP } from '../../../../shared/constants' import type { FeatureWallTourDepthSummary } from '../../../../shared/feature-wall-tour-depth' import { isGitRepoKind } from '../../../../shared/repo-kind' +import { + buildNestedRepoImportActionTelemetry, + buildNestedRepoImportResultTelemetry, + buildNestedRepoScanTelemetry, + createNestedRepoTelemetryAttemptId, + shouldEmitNestedRepoImportSubmitTelemetry, + type NestedRepoTelemetryRuntimeKind +} from '../../../../shared/nested-repo-telemetry' import type { EventProps } from '../../../../shared/telemetry-events' import type { GlobalSettings, @@ -135,6 +143,10 @@ export function useOnboardingFlow( const [nestedScan, setNestedScan] = useState(null) const [nestedSelectedPaths, setNestedSelectedPaths] = useState>(new Set()) const [nestedGroupName, setNestedGroupName] = useState('') + const [nestedAttemptId, setNestedAttemptId] = useState(null) + const [nestedRuntimeKind, setNestedRuntimeKind] = useState( + null + ) const [tourStarted, setTourStarted] = useState(false) const [busyLabel, setBusyLabel] = useState(null) const [error, setError] = useState(null) @@ -554,11 +566,24 @@ export function useOnboardingFlow( ] ) - const showNestedRepoReview = useCallback((scan: NestedRepoScanResult, selectedPath: string) => { - setNestedScan(scan) - setNestedSelectedPaths(new Set(scan.repos.map((repo) => repo.path))) - setNestedGroupName(defaultProjectGroupNameForPath(selectedPath)) - }, []) + const showNestedRepoReview = useCallback( + ( + scan: NestedRepoScanResult, + selectedPath: string, + attemptId: string, + runtimeKind: NestedRepoTelemetryRuntimeKind + ) => { + setNestedScan(scan) + setNestedSelectedPaths(new Set(scan.repos.map((repo) => repo.path))) + setNestedGroupName(defaultProjectGroupNameForPath(selectedPath)) + setNestedAttemptId(attemptId) + setNestedRuntimeKind(runtimeKind) + }, + [] + ) + + const onboardingNestedRepoRuntimeKind: NestedRepoTelemetryRuntimeKind = + settings?.activeRuntimeEnvironmentId?.trim() ? 'runtime' : 'local' const startFeatureSetup = useCallback(async () => { if ( @@ -614,9 +639,19 @@ export function useOnboardingFlow( setBusyLabel(kind === 'git' ? 'Scanning for repositories…' : 'Opening folder…') try { if (kind === 'git') { + const attemptId = createNestedRepoTelemetryAttemptId() const scan = await scanNestedRepos(path) + track( + 'add_repo_nested_scan_result', + buildNestedRepoScanTelemetry({ + attemptId, + surface: 'onboarding', + runtimeKind: 'runtime', + scan + }) + ) if (scan?.selectedPathKind === 'non_git_folder' && scan.repos.length > 0) { - showNestedRepoReview(scan, path) + showNestedRepoReview(scan, path, attemptId, 'runtime') return } } @@ -645,9 +680,19 @@ export function useOnboardingFlow( try { let result = await window.api.repos.add({ path }) if ('error' in result && result.error.includes('Not a valid git repository')) { + const attemptId = createNestedRepoTelemetryAttemptId() const scan = await scanNestedRepos(path) + track( + 'add_repo_nested_scan_result', + buildNestedRepoScanTelemetry({ + attemptId, + surface: 'onboarding', + runtimeKind: 'local', + scan + }) + ) if (scan?.selectedPathKind === 'non_git_folder' && scan.repos.length > 0) { - showNestedRepoReview(scan, path) + showNestedRepoReview(scan, path, attemptId, 'local') return } result = await window.api.repos.add({ path, kind: 'folder' }) @@ -676,11 +721,35 @@ export function useOnboardingFlow( const importNested = useCallback( async (mode: 'group' | 'separate') => { - if (!nestedScan || nestedSelectedPaths.size === 0 || busyLabel !== null) { + const attemptId = nestedAttemptId + if ( + !nestedScan || + !attemptId || + !shouldEmitNestedRepoImportSubmitTelemetry({ + attemptId, + selectedCount: nestedSelectedPaths.size, + isBusy: busyLabel !== null + }) + ) { return } + const foundCount = nestedScan.repos.length + const selectedCount = nestedSelectedPaths.size + const runtimeKind = nestedRuntimeKind ?? onboardingNestedRepoRuntimeKind setError(null) setBusyLabel('Importing repositories…') + track( + 'add_repo_nested_import_action', + buildNestedRepoImportActionTelemetry({ + attemptId, + surface: 'onboarding', + runtimeKind, + action: mode === 'group' ? 'import_group' : 'import_separate', + foundCount, + selectedCount + }) + ) + let resultTracked = false try { const result = await importNestedRepos({ parentPath: nestedScan.selectedPath, @@ -688,6 +757,19 @@ export function useOnboardingFlow( projectPaths: [...nestedSelectedPaths], mode }) + track( + 'add_repo_nested_import_result', + buildNestedRepoImportResultTelemetry({ + attemptId, + surface: 'onboarding', + runtimeKind, + mode, + foundCount, + selectedCount, + result + }) + ) + resultTracked = true const importedRepoIds = result?.projects .map((entry) => entry.projectId) @@ -701,6 +783,20 @@ export function useOnboardingFlow( } await completeRepo(projectId, true, 'open_folder') } catch (err) { + if (!resultTracked) { + track( + 'add_repo_nested_import_result', + buildNestedRepoImportResultTelemetry({ + attemptId, + surface: 'onboarding', + runtimeKind, + mode, + foundCount, + selectedCount, + result: null + }) + ) + } setError(err instanceof Error ? err.message : String(err)) track('onboarding_step4_path_failed', { path: 'open_folder', reason: 'invalid_path' }) } finally { @@ -713,22 +809,54 @@ export function useOnboardingFlow( fetchWorktrees, importNestedRepos, nestedGroupName, + nestedAttemptId, nestedScan, - nestedSelectedPaths + nestedSelectedPaths, + nestedRuntimeKind, + onboardingNestedRepoRuntimeKind ] ) + const trackNestedBackAndClear = useCallback(() => { + if (nestedScan && nestedAttemptId) { + track( + 'add_repo_nested_import_action', + buildNestedRepoImportActionTelemetry({ + attemptId: nestedAttemptId, + surface: 'onboarding', + runtimeKind: nestedRuntimeKind ?? onboardingNestedRepoRuntimeKind, + action: 'back', + foundCount: nestedScan.repos.length, + selectedCount: nestedSelectedPaths.size + }) + ) + } + setNestedScan(null) + setNestedSelectedPaths(new Set()) + setNestedGroupName('') + setNestedAttemptId(null) + setNestedRuntimeKind(null) + setError(null) + }, [ + nestedAttemptId, + nestedRuntimeKind, + nestedScan, + nestedSelectedPaths.size, + onboardingNestedRepoRuntimeKind + ]) + // Why: lets the user back out of the nested-repo step in onboarding to // re-pick a folder/clone target. Mirrors the dialog's left-aligned Back. const cancelNested = useCallback(() => { if (busyLabel !== null) { return } - setNestedScan(null) - setNestedSelectedPaths(new Set()) - setNestedGroupName('') - setError(null) - }, [busyLabel]) + trackNestedBackAndClear() + }, [busyLabel, trackNestedBackAndClear]) + + const canImportNestedForTelemetry = useCallback((): boolean => { + return Boolean(nestedScan && nestedAttemptId && nestedSelectedPaths.size > 0) + }, [nestedAttemptId, nestedScan, nestedSelectedPaths.size]) const clone = useCallback(async () => { // Why: re-entry guard — prevents Enter spamming from triggering duplicate clones. @@ -885,11 +1013,22 @@ export function useOnboardingFlow( advancedVia }) if (closed) { + if (nestedScan) { + trackNestedBackAndClear() + } emitPendingTourOutcome() } return closed }, - [busyLabel, closeWith, consumeStepDurationMs, currentStep.stepNumber, emitPendingTourOutcome] + [ + busyLabel, + closeWith, + consumeStepDurationMs, + currentStep.stepNumber, + emitPendingTourOutcome, + nestedScan, + trackNestedBackAndClear + ] ) const startTour = useCallback(() => { @@ -1058,9 +1197,13 @@ export function useOnboardingFlow( ]) const back = useCallback(() => { + if (nestedScan) { + trackNestedBackAndClear() + return + } setTourStarted(false) setStepIndex((idx) => Math.max(idx - 1, 0)) - }, []) + }, [nestedScan, trackNestedBackAndClear]) // Why: returns the user to the "Take the tour" intro without leaving the // tour step. Don't emit the tour outcome here — re-entry must still let @@ -1070,10 +1213,16 @@ export function useOnboardingFlow( setTourStarted(false) }, []) - const jumpToStep = useCallback((idx: number) => { - setTourStarted(false) - setStepIndex(Math.min(Math.max(idx, 0), STEPS.length - 1)) - }, []) + const jumpToStep = useCallback( + (idx: number) => { + if (nestedScan && idx !== stepIndex) { + trackNestedBackAndClear() + } + setTourStarted(false) + setStepIndex(Math.min(Math.max(idx, 0), STEPS.length - 1)) + }, + [nestedScan, stepIndex, trackNestedBackAndClear] + ) return { settings, @@ -1098,6 +1247,7 @@ export function useOnboardingFlow( setNestedGroupName, importNested, cancelNested, + canImportNestedForTelemetry, hasExistingProject, serverPath, setServerPath, diff --git a/src/renderer/src/components/sidebar/AddRepoDialog.tsx b/src/renderer/src/components/sidebar/AddRepoDialog.tsx index 701f93c8b4a..647ea521154 100644 --- a/src/renderer/src/components/sidebar/AddRepoDialog.tsx +++ b/src/renderer/src/components/sidebar/AddRepoDialog.tsx @@ -21,6 +21,14 @@ import { getDefaultCloneParent } from './clone-defaults' import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' import { activateAndRevealWorktree } from '@/lib/worktree-activation' import { isGitRepoKind } from '../../../../shared/repo-kind' +import { + buildNestedRepoImportActionTelemetry, + buildNestedRepoImportResultTelemetry, + buildNestedRepoScanTelemetry, + createNestedRepoTelemetryAttemptId, + shouldEmitNestedRepoImportSubmitTelemetry, + type NestedRepoTelemetryRuntimeKind +} from '../../../../shared/nested-repo-telemetry' import type { AddRepoExistingWorkspaceSource, AddRepoSetupStepAction @@ -83,6 +91,20 @@ const AddRepoDialog = React.memo(function AddRepoDialog() { const [nestedSelectedPaths, setNestedSelectedPaths] = useState>(new Set()) const [nestedGroupName, setNestedGroupName] = useState('') const [nestedConnectionId, setNestedConnectionId] = useState(null) + const [nestedAttemptId, setNestedAttemptId] = useState(null) + const [nestedRuntimeKind, setNestedRuntimeKind] = useState( + null + ) + + const getNestedRepoRuntimeKind = useCallback( + (connectionId: string | null): NestedRepoTelemetryRuntimeKind => { + if (connectionId) { + return 'ssh' + } + return settings?.activeRuntimeEnvironmentId?.trim() ? 'runtime' : 'local' + }, + [settings?.activeRuntimeEnvironmentId] + ) // Why: monotonic ID so stale clone callbacks can detect they were superseded. const cloneGenRef = useRef(0) @@ -110,12 +132,25 @@ const AddRepoDialog = React.memo(function AddRepoDialog() { closeModal, setExistingWorkspaceSource, scanNestedRepos, - (scan, selectedPath, connectionId) => { + (scan, selectedPath, connectionId, attemptId) => { setNestedScan(scan) setNestedSelectedPaths(new Set(scan.repos.map((repo) => repo.path))) setNestedGroupName(defaultProjectGroupNameForPath(scan.selectedPath || selectedPath)) setNestedConnectionId(connectionId) + setNestedAttemptId(attemptId) + setNestedRuntimeKind('ssh') setStep('nested') + }, + (scan, attemptId) => { + track( + 'add_repo_nested_scan_result', + buildNestedRepoScanTelemetry({ + attemptId, + surface: 'sidebar', + runtimeKind: 'ssh', + scan + }) + ) } ) @@ -217,6 +252,8 @@ const AddRepoDialog = React.memo(function AddRepoDialog() { setNestedSelectedPaths(new Set()) setNestedGroupName('') setNestedConnectionId(null) + setNestedAttemptId(null) + setNestedRuntimeKind(null) resetCreateState() resetRemoteState() }, [resetRemoteState, resetCreateState]) @@ -242,12 +279,24 @@ const AddRepoDialog = React.memo(function AddRepoDialog() { if (!path) { return } + const attemptId = createNestedRepoTelemetryAttemptId() const scan = await scanNestedRepos(path) + track( + 'add_repo_nested_scan_result', + buildNestedRepoScanTelemetry({ + attemptId, + surface: 'sidebar', + runtimeKind: 'local', + scan + }) + ) if (scan?.selectedPathKind === 'non_git_folder' && scan.repos.length > 0) { setNestedScan(scan) setNestedSelectedPaths(new Set(scan.repos.map((repo) => repo.path))) setNestedGroupName(defaultProjectGroupNameForPath(path)) setNestedConnectionId(null) + setNestedAttemptId(attemptId) + setNestedRuntimeKind('local') setStep('nested') return } @@ -269,10 +318,33 @@ const AddRepoDialog = React.memo(function AddRepoDialog() { const handleImportNestedRepos = useCallback( async (mode: 'group' | 'separate') => { - if (!nestedScan || nestedSelectedPaths.size === 0) { + const attemptId = nestedAttemptId + if ( + !nestedScan || + !attemptId || + !shouldEmitNestedRepoImportSubmitTelemetry({ + attemptId, + selectedCount: nestedSelectedPaths.size + }) + ) { return } + const foundCount = nestedScan.repos.length + const selectedCount = nestedSelectedPaths.size + const runtimeKind = nestedRuntimeKind ?? getNestedRepoRuntimeKind(nestedConnectionId) setIsAdding(true) + track( + 'add_repo_nested_import_action', + buildNestedRepoImportActionTelemetry({ + attemptId, + surface: 'sidebar', + runtimeKind, + action: mode === 'group' ? 'import_group' : 'import_separate', + foundCount, + selectedCount + }) + ) + let resultTracked = false try { const result = await importNestedRepos({ parentPath: nestedScan.selectedPath, @@ -281,6 +353,19 @@ const AddRepoDialog = React.memo(function AddRepoDialog() { ...(nestedConnectionId ? { connectionId: nestedConnectionId } : {}), mode }) + track( + 'add_repo_nested_import_result', + buildNestedRepoImportResultTelemetry({ + attemptId, + surface: 'sidebar', + runtimeKind, + mode, + foundCount, + selectedCount, + result + }) + ) + resultTracked = true if (!result) { return } @@ -313,6 +398,20 @@ const AddRepoDialog = React.memo(function AddRepoDialog() { }) } } finally { + if (!resultTracked) { + track( + 'add_repo_nested_import_result', + buildNestedRepoImportResultTelemetry({ + attemptId, + surface: 'sidebar', + runtimeKind, + mode, + foundCount, + selectedCount, + result: null + }) + ) + } setIsAdding(false) } }, @@ -320,9 +419,12 @@ const AddRepoDialog = React.memo(function AddRepoDialog() { fetchWorktrees, importNestedRepos, nestedGroupName, + nestedAttemptId, nestedScan, nestedSelectedPaths, nestedConnectionId, + nestedRuntimeKind, + getNestedRepoRuntimeKind, settings?.activeRuntimeEnvironmentId ] ) @@ -336,12 +438,24 @@ const AddRepoDialog = React.memo(function AddRepoDialog() { setIsAddingServerPath(true) try { if (kind === 'git') { + const attemptId = createNestedRepoTelemetryAttemptId() const scan = await scanNestedRepos(path) + track( + 'add_repo_nested_scan_result', + buildNestedRepoScanTelemetry({ + attemptId, + surface: 'sidebar', + runtimeKind: getNestedRepoRuntimeKind(null), + scan + }) + ) if (scan?.selectedPathKind === 'non_git_folder' && scan.repos.length > 0) { setNestedScan(scan) setNestedSelectedPaths(new Set(scan.repos.map((repo) => repo.path))) setNestedGroupName(defaultProjectGroupNameForPath(path)) setNestedConnectionId(null) + setNestedAttemptId(attemptId) + setNestedRuntimeKind(getNestedRepoRuntimeKind(null)) setStep('nested') return } @@ -361,7 +475,7 @@ const AddRepoDialog = React.memo(function AddRepoDialog() { setIsAddingServerPath(false) } }, - [addRepoPath, closeModal, fetchWorktrees, scanNestedRepos, serverPath] + [addRepoPath, closeModal, fetchWorktrees, getNestedRepoRuntimeKind, scanNestedRepos, serverPath] ) const handlePickDestination = useCallback(async () => { @@ -550,8 +664,36 @@ const AddRepoDialog = React.memo(function AddRepoDialog() { updateRepo ]) + const trackNestedBackAction = useCallback((): void => { + if (nestedScan && nestedAttemptId) { + track( + 'add_repo_nested_import_action', + buildNestedRepoImportActionTelemetry({ + attemptId: nestedAttemptId, + surface: 'sidebar', + runtimeKind: nestedRuntimeKind ?? getNestedRepoRuntimeKind(nestedConnectionId), + action: 'back', + foundCount: nestedScan.repos.length, + selectedCount: nestedSelectedPaths.size + }) + ) + } + }, [ + getNestedRepoRuntimeKind, + nestedAttemptId, + nestedConnectionId, + nestedRuntimeKind, + nestedScan, + nestedSelectedPaths.size + ]) + // Why: handleBack reuses resetState which already aborts clones and resets all fields. - const handleBack = resetState + const handleBack = useCallback(() => { + if (step === 'nested') { + trackNestedBackAction() + } + resetState() + }, [resetState, step, trackNestedBackAction]) // Why: only the Setup step's "Add another project" back arrow counts as a // funnel event — the in-flight Back arrows on clone/remote/create are not @@ -576,6 +718,9 @@ const AddRepoDialog = React.memo(function AddRepoDialog() { void finishImportedRepoWithoutOpening() return } + if (step === 'nested' && !isAdding) { + trackNestedBackAction() + } closeModal() resetState() } diff --git a/src/renderer/src/components/sidebar/AddRepoSteps.tsx b/src/renderer/src/components/sidebar/AddRepoSteps.tsx index cd57ce2183a..8d1bbf66455 100644 --- a/src/renderer/src/components/sidebar/AddRepoSteps.tsx +++ b/src/renderer/src/components/sidebar/AddRepoSteps.tsx @@ -17,6 +17,7 @@ import { SshTargetRow } from './SshTargetRow' import type { AddRepoExistingWorkspaceSource } from '../../../../shared/telemetry-events' import type { NestedRepoScanResult, Repo } from '../../../../shared/types' import type { SshTarget, SshConnectionState } from '../../../../shared/ssh-types' +import { createNestedRepoTelemetryAttemptId } from '../../../../shared/nested-repo-telemetry' // ── Remote project hook ───────────────────────────────────────────── @@ -30,8 +31,10 @@ export function useRemoteRepo( showNestedRepoReview?: ( scan: NestedRepoScanResult, selectedPath: string, - connectionId: string - ) => void + connectionId: string, + attemptId: string + ) => void, + onNestedScanResult?: (scan: NestedRepoScanResult | null, attemptId: string) => void ) { const [sshTargets, setSshTargets] = useState<(SshTarget & { state?: SshConnectionState })[]>([]) const [selectedTargetId, setSelectedTargetId] = useState(null) @@ -111,9 +114,11 @@ export function useRemoteRepo( setIsAddingRemote(true) setRemoteError(null) try { + const attemptId = createNestedRepoTelemetryAttemptId() const scan = await scanNestedRepos?.(trimmedRemotePath, selectedTargetId) + onNestedScanResult?.(scan ?? null, attemptId) if (scan?.selectedPathKind === 'non_git_folder' && scan.repos.length > 0) { - showNestedRepoReview?.(scan, trimmedRemotePath, selectedTargetId) + showNestedRepoReview?.(scan, trimmedRemotePath, selectedTargetId, attemptId) return } const result = await window.api.repos.addRemote({ @@ -165,6 +170,7 @@ export function useRemoteRepo( remotePath, scanNestedRepos, showNestedRepoReview, + onNestedScanResult, fetchWorktrees, setStep, setAddedRepo, diff --git a/src/shared/nested-repo-telemetry-schema.test.ts b/src/shared/nested-repo-telemetry-schema.test.ts new file mode 100644 index 00000000000..7b9450a6b3b --- /dev/null +++ b/src/shared/nested-repo-telemetry-schema.test.ts @@ -0,0 +1,171 @@ +import { describe, expect, it } from 'vitest' +import { eventSchemas } from './telemetry-events' + +const attemptId = '2fbac1e3-5094-45b4-80a6-90281e6e9e09' + +describe('nested repo import telemetry schemas', () => { + it('accepts scan, action, and result telemetry payloads', () => { + expect( + eventSchemas.add_repo_nested_scan_result.safeParse({ + attempt_id: attemptId, + surface: 'onboarding', + runtime_kind: 'local', + result: 'review_shown', + selected_path_kind: 'non_git_folder', + found_count: 3, + found_count_bucket: '2-3', + truncated: false, + timed_out: false + }).success + ).toBe(true) + + expect( + eventSchemas.add_repo_nested_import_action.safeParse({ + attempt_id: attemptId, + surface: 'sidebar', + runtime_kind: 'ssh', + action: 'import_group', + found_count: 3, + found_count_bucket: '2-3', + selected_count: 3, + selected_count_bucket: '2-3', + all_selected: true + }).success + ).toBe(true) + + expect( + eventSchemas.add_repo_nested_import_result.safeParse({ + attempt_id: attemptId, + surface: 'onboarding', + runtime_kind: 'runtime', + mode: 'group', + outcome: 'success', + found_count: 3, + found_count_bucket: '2-3', + selected_count: 3, + selected_count_bucket: '2-3', + imported_count: 3, + imported_count_bucket: '2-3', + already_known_count: 0, + already_known_count_bucket: '0', + failed_count: 0, + failed_count_bucket: '0', + all_selected: true + }).success + ).toBe(true) + }) + + it('rejects raw paths, repo names, and raw errors via .strict()', () => { + expect( + eventSchemas.add_repo_nested_scan_result.safeParse({ + attempt_id: attemptId, + surface: 'onboarding', + runtime_kind: 'local', + result: 'review_shown', + selected_path_kind: 'non_git_folder', + found_count: 1, + found_count_bucket: '1', + truncated: false, + timed_out: false, + selected_path: '/Users/alice/work/platform' + }).success + ).toBe(false) + + expect( + eventSchemas.add_repo_nested_import_action.safeParse({ + attempt_id: attemptId, + surface: 'sidebar', + runtime_kind: 'local', + action: 'import_group', + found_count: 1, + found_count_bucket: '1', + selected_count: 1, + selected_count_bucket: '1', + all_selected: true, + repo_name: 'secret-service' + }).success + ).toBe(false) + + expect( + eventSchemas.add_repo_nested_import_result.safeParse({ + attempt_id: attemptId, + surface: 'sidebar', + runtime_kind: 'ssh', + mode: 'group', + outcome: 'failed', + found_count: 1, + found_count_bucket: '1', + selected_count: 1, + selected_count_bucket: '1', + imported_count: 0, + imported_count_bucket: '0', + already_known_count: 0, + already_known_count_bucket: '0', + failed_count: 1, + failed_count_bucket: '1', + all_selected: true, + error_message: 'failed at /Users/alice/work/platform' + }).success + ).toBe(false) + }) + + it('rejects unbounded imported counts', () => { + const parsed = eventSchemas.add_repo_nested_import_result.safeParse({ + attempt_id: attemptId, + surface: 'onboarding', + runtime_kind: 'local', + mode: 'separate', + outcome: 'success', + found_count: 501, + found_count_bucket: '16+', + selected_count: 501, + selected_count_bucket: '16+', + imported_count: 501, + imported_count_bucket: '16+', + already_known_count: 0, + already_known_count_bucket: '0', + failed_count: 0, + failed_count_bucket: '0', + all_selected: true + }) + + expect(parsed.success).toBe(false) + }) + + it('rejects mismatched exact counts and buckets', () => { + expect( + eventSchemas.add_repo_nested_import_action.safeParse({ + attempt_id: attemptId, + surface: 'sidebar', + runtime_kind: 'local', + action: 'import_group', + found_count: 1, + found_count_bucket: '16+', + selected_count: 1, + selected_count_bucket: '1', + all_selected: true + }).success + ).toBe(false) + + expect( + eventSchemas.add_repo_nested_import_result.safeParse({ + attempt_id: attemptId, + surface: 'sidebar', + runtime_kind: 'local', + mode: 'group', + outcome: 'success', + found_count: 2, + found_count_bucket: '2-3', + selected_count: 2, + selected_count_bucket: '2-3', + imported_count: 2, + imported_count_bucket: '16+', + already_known_count: 0, + already_known_count_bucket: '0', + failed_count: 0, + failed_count_bucket: '0', + all_selected: true + }).success + ).toBe(false) + }) +}) diff --git a/src/shared/nested-repo-telemetry.test.ts b/src/shared/nested-repo-telemetry.test.ts new file mode 100644 index 00000000000..4e7db731b9d --- /dev/null +++ b/src/shared/nested-repo-telemetry.test.ts @@ -0,0 +1,221 @@ +import { describe, expect, it } from 'vitest' +import { + NESTED_REPO_TELEMETRY_MAX_REPO_COUNT, + bucketNestedRepoTelemetryCount, + buildNestedRepoImportActionTelemetry, + buildNestedRepoImportResultTelemetry, + buildNestedRepoScanTelemetry, + capNestedRepoTelemetryCount, + createNestedRepoTelemetryAttemptId, + shouldEmitNestedRepoImportSubmitTelemetry +} from './nested-repo-telemetry' +import type { NestedRepoScanResult, ProjectGroupImportResult } from './types' + +const scanResult: NestedRepoScanResult = { + selectedPath: '/workspace/platform', + selectedPathKind: 'non_git_folder', + repos: [ + { path: '/workspace/platform/apps/web', displayName: 'web', depth: 2 }, + { path: '/workspace/platform/services/api', displayName: 'api', depth: 2 }, + { path: '/workspace/platform/services/billing', displayName: 'billing', depth: 2 } + ], + truncated: false, + timedOut: false, + durationMs: 42, + maxDepth: 3 +} +const attemptId = '2fbac1e3-5094-45b4-80a6-90281e6e9e09' +const nextAttemptId = 'd22bb9e0-b7f8-480a-8a2a-9b34f84f2c42' + +describe('nested repo telemetry payloads', () => { + it('caps and buckets repo counts for low-cardinality breakdowns', () => { + expect(capNestedRepoTelemetryCount(-1)).toBe(0) + expect(capNestedRepoTelemetryCount(2.9)).toBe(2) + expect(capNestedRepoTelemetryCount(Number.NaN)).toBe(0) + expect(capNestedRepoTelemetryCount(999)).toBe(NESTED_REPO_TELEMETRY_MAX_REPO_COUNT) + + expect(bucketNestedRepoTelemetryCount(0)).toBe('0') + expect(bucketNestedRepoTelemetryCount(1)).toBe('1') + expect(bucketNestedRepoTelemetryCount(3)).toBe('2-3') + expect(bucketNestedRepoTelemetryCount(7)).toBe('4-7') + expect(bucketNestedRepoTelemetryCount(15)).toBe('8-15') + expect(bucketNestedRepoTelemetryCount(16)).toBe('16+') + }) + + it('classifies a scan that should show nested repo review', () => { + expect( + buildNestedRepoScanTelemetry({ + attemptId, + surface: 'onboarding', + runtimeKind: 'local', + scan: scanResult + }) + ).toEqual({ + attempt_id: attemptId, + surface: 'onboarding', + runtime_kind: 'local', + result: 'review_shown', + selected_path_kind: 'non_git_folder', + found_count: 3, + found_count_bucket: '2-3', + truncated: false, + timed_out: false + }) + }) + + it('records import action selection without raw path details', () => { + expect( + buildNestedRepoImportActionTelemetry({ + attemptId, + surface: 'sidebar', + runtimeKind: 'ssh', + action: 'import_group', + foundCount: 3, + selectedCount: 2 + }) + ).toEqual({ + attempt_id: attemptId, + surface: 'sidebar', + runtime_kind: 'ssh', + action: 'import_group', + found_count: 3, + found_count_bucket: '2-3', + selected_count: 2, + selected_count_bucket: '2-3', + all_selected: false + }) + }) + + it('computes all_selected from raw counts before caps are applied', () => { + const action = buildNestedRepoImportActionTelemetry({ + attemptId, + surface: 'sidebar', + runtimeKind: 'local', + action: 'import_group', + foundCount: 600, + selectedCount: 500 + }) + const result = buildNestedRepoImportResultTelemetry({ + attemptId, + surface: 'sidebar', + runtimeKind: 'local', + mode: 'group', + foundCount: 600, + selectedCount: 500, + result: { importedCount: 500, alreadyKnownCount: 0, failedCount: 0, projects: [] } + }) + + expect(action.found_count).toBe(500) + expect(action.selected_count).toBe(500) + expect(action.all_selected).toBe(false) + expect(result.all_selected).toBe(false) + }) + + it('keeps exact imported counts on import result payloads', () => { + const result: ProjectGroupImportResult = { + importedCount: 2, + alreadyKnownCount: 1, + failedCount: 1, + projects: [ + { path: '/workspace/platform/apps/web', projectId: 'web', status: 'imported' }, + { path: '/workspace/platform/services/api', projectId: 'api', status: 'imported' }, + { + path: '/workspace/platform/services/billing', + projectId: 'billing', + status: 'already-known' + }, + { path: '/workspace/platform/tools/cli', status: 'failed', error: 'Not a git repo' } + ] + } + + expect( + buildNestedRepoImportResultTelemetry({ + attemptId, + surface: 'onboarding', + runtimeKind: 'runtime', + mode: 'group', + foundCount: 4, + selectedCount: 4, + result + }) + ).toMatchObject({ + attempt_id: attemptId, + surface: 'onboarding', + runtime_kind: 'runtime', + mode: 'group', + outcome: 'partial_failure', + found_count: 4, + selected_count: 4, + imported_count: 2, + already_known_count: 1, + failed_count: 1, + all_selected: true + }) + }) + + it('generates non-persistent random attempt ids', () => { + const first = createNestedRepoTelemetryAttemptId() + const second = createNestedRepoTelemetryAttemptId() + + expect(first).toMatch(/^[0-9a-f-]{36}$/) + expect(second).not.toBe(first) + }) + + it('threads one attempt id across scan, action, and result and allows a new scan id', () => { + const scan = buildNestedRepoScanTelemetry({ + attemptId, + surface: 'sidebar', + runtimeKind: 'local', + scan: scanResult + }) + const action = buildNestedRepoImportActionTelemetry({ + attemptId, + surface: 'sidebar', + runtimeKind: 'local', + action: 'import_separate', + foundCount: 3, + selectedCount: 3 + }) + const result = buildNestedRepoImportResultTelemetry({ + attemptId, + surface: 'sidebar', + runtimeKind: 'local', + mode: 'separate', + foundCount: 3, + selectedCount: 3, + result: { importedCount: 3, alreadyKnownCount: 0, failedCount: 0, projects: [] } + }) + const nextScan = buildNestedRepoScanTelemetry({ + attemptId: nextAttemptId, + surface: 'sidebar', + runtimeKind: 'local', + scan: scanResult + }) + + expect(action.attempt_id).toBe(scan.attempt_id) + expect(result.attempt_id).toBe(scan.attempt_id) + expect(nextScan.attempt_id).not.toBe(scan.attempt_id) + }) + + it('prevents zero-selection submit telemetry', () => { + expect( + shouldEmitNestedRepoImportSubmitTelemetry({ + attemptId, + selectedCount: 0 + }) + ).toBe(false) + expect( + shouldEmitNestedRepoImportSubmitTelemetry({ + attemptId, + selectedCount: 1, + isBusy: true + }) + ).toBe(false) + expect( + shouldEmitNestedRepoImportSubmitTelemetry({ + attemptId, + selectedCount: 1 + }) + ).toBe(true) + }) +}) diff --git a/src/shared/nested-repo-telemetry.ts b/src/shared/nested-repo-telemetry.ts new file mode 100644 index 00000000000..4f5aca068e8 --- /dev/null +++ b/src/shared/nested-repo-telemetry.ts @@ -0,0 +1,228 @@ +import type { + NestedRepoScanResult, + ProjectGroupImportMode, + ProjectGroupImportResult +} from './types' + +export const NESTED_REPO_TELEMETRY_MAX_REPO_COUNT = 500 + +export const NESTED_REPO_TELEMETRY_SURFACES = ['onboarding', 'sidebar'] as const +export type NestedRepoTelemetrySurface = (typeof NESTED_REPO_TELEMETRY_SURFACES)[number] + +export const NESTED_REPO_TELEMETRY_RUNTIME_KINDS = ['local', 'runtime', 'ssh'] as const +export type NestedRepoTelemetryRuntimeKind = (typeof NESTED_REPO_TELEMETRY_RUNTIME_KINDS)[number] + +export const NESTED_REPO_SCAN_RESULTS = [ + 'review_shown', + 'git_repo', + 'no_nested_repos', + 'scan_failed' +] as const +export type NestedRepoScanTelemetryResult = (typeof NESTED_REPO_SCAN_RESULTS)[number] + +export const NESTED_REPO_IMPORT_ACTIONS = ['import_group', 'import_separate', 'back'] as const +export type NestedRepoImportTelemetryAction = (typeof NESTED_REPO_IMPORT_ACTIONS)[number] + +export const NESTED_REPO_IMPORT_OUTCOMES = ['success', 'partial_failure', 'failed'] as const +export type NestedRepoImportTelemetryOutcome = (typeof NESTED_REPO_IMPORT_OUTCOMES)[number] + +export const NESTED_REPO_COUNT_BUCKETS = ['0', '1', '2-3', '4-7', '8-15', '16+'] as const +export type NestedRepoCountBucket = (typeof NESTED_REPO_COUNT_BUCKETS)[number] + +type NestedRepoTelemetryBase = { + attempt_id: string + surface: NestedRepoTelemetrySurface + runtime_kind: NestedRepoTelemetryRuntimeKind +} + +export type NestedRepoScanTelemetry = NestedRepoTelemetryBase & { + result: NestedRepoScanTelemetryResult + selected_path_kind?: NestedRepoScanResult['selectedPathKind'] + found_count: number + found_count_bucket: NestedRepoCountBucket + truncated: boolean + timed_out: boolean +} + +export type NestedRepoImportActionTelemetry = NestedRepoTelemetryBase & { + action: NestedRepoImportTelemetryAction + found_count: number + found_count_bucket: NestedRepoCountBucket + selected_count: number + selected_count_bucket: NestedRepoCountBucket + all_selected: boolean +} + +export type NestedRepoImportResultTelemetry = NestedRepoTelemetryBase & { + mode: ProjectGroupImportMode + outcome: NestedRepoImportTelemetryOutcome + found_count: number + found_count_bucket: NestedRepoCountBucket + selected_count: number + selected_count_bucket: NestedRepoCountBucket + imported_count: number + imported_count_bucket: NestedRepoCountBucket + already_known_count: number + already_known_count_bucket: NestedRepoCountBucket + failed_count: number + failed_count_bucket: NestedRepoCountBucket + all_selected: boolean +} + +export function capNestedRepoTelemetryCount(count: number): number { + if (!Number.isFinite(count)) { + return 0 + } + return Math.max(0, Math.min(NESTED_REPO_TELEMETRY_MAX_REPO_COUNT, Math.floor(count))) +} + +function normalizeNestedRepoTelemetryCount(count: number): number { + if (!Number.isFinite(count)) { + return 0 + } + return Math.max(0, Math.floor(count)) +} + +export function bucketNestedRepoTelemetryCount(count: number): NestedRepoCountBucket { + const capped = capNestedRepoTelemetryCount(count) + if (capped === 0) { + return '0' + } + if (capped === 1) { + return '1' + } + if (capped <= 3) { + return '2-3' + } + if (capped <= 7) { + return '4-7' + } + if (capped <= 15) { + return '8-15' + } + return '16+' +} + +export function shouldEmitNestedRepoImportSubmitTelemetry(args: { + attemptId: string | null + selectedCount: number + isBusy?: boolean +}): boolean { + return Boolean(args.attemptId && args.selectedCount > 0 && !args.isBusy) +} + +export function createNestedRepoTelemetryAttemptId(): string { + const cryptoApi = globalThis.crypto + if (typeof cryptoApi?.randomUUID === 'function') { + return cryptoApi.randomUUID() + } + + const bytes = new Uint8Array(16) + if (typeof cryptoApi?.getRandomValues === 'function') { + cryptoApi.getRandomValues(bytes) + } else { + for (let i = 0; i < bytes.length; i++) { + bytes[i] = Math.floor(Math.random() * 256) + } + } + + // Why: keep the fallback schema-compatible without deriving from any stable repo input. + bytes[6] = (bytes[6] & 0x0f) | 0x40 + bytes[8] = (bytes[8] & 0x3f) | 0x80 + const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')) + return `${hex.slice(0, 4).join('')}-${hex.slice(4, 6).join('')}-${hex.slice(6, 8).join('')}-${hex.slice(8, 10).join('')}-${hex.slice(10, 16).join('')}` +} + +export function buildNestedRepoScanTelemetry(args: { + attemptId: string + surface: NestedRepoTelemetrySurface + runtimeKind: NestedRepoTelemetryRuntimeKind + scan: NestedRepoScanResult | null +}): NestedRepoScanTelemetry { + const foundCount = capNestedRepoTelemetryCount(args.scan?.repos.length ?? 0) + const result: NestedRepoScanTelemetryResult = + args.scan === null + ? 'scan_failed' + : args.scan.selectedPathKind === 'git_repo' + ? 'git_repo' + : foundCount > 0 + ? 'review_shown' + : 'no_nested_repos' + + return { + attempt_id: args.attemptId, + surface: args.surface, + runtime_kind: args.runtimeKind, + result, + ...(args.scan ? { selected_path_kind: args.scan.selectedPathKind } : {}), + found_count: foundCount, + found_count_bucket: bucketNestedRepoTelemetryCount(foundCount), + truncated: args.scan?.truncated ?? false, + timed_out: args.scan?.timedOut ?? false + } +} + +export function buildNestedRepoImportActionTelemetry(args: { + attemptId: string + surface: NestedRepoTelemetrySurface + runtimeKind: NestedRepoTelemetryRuntimeKind + action: NestedRepoImportTelemetryAction + foundCount: number + selectedCount: number +}): NestedRepoImportActionTelemetry { + const rawFoundCount = normalizeNestedRepoTelemetryCount(args.foundCount) + const rawSelectedCount = normalizeNestedRepoTelemetryCount(args.selectedCount) + const foundCount = capNestedRepoTelemetryCount(args.foundCount) + const selectedCount = capNestedRepoTelemetryCount(args.selectedCount) + return { + attempt_id: args.attemptId, + surface: args.surface, + runtime_kind: args.runtimeKind, + action: args.action, + found_count: foundCount, + found_count_bucket: bucketNestedRepoTelemetryCount(foundCount), + selected_count: selectedCount, + selected_count_bucket: bucketNestedRepoTelemetryCount(selectedCount), + all_selected: rawFoundCount > 0 && rawSelectedCount === rawFoundCount + } +} + +export function buildNestedRepoImportResultTelemetry(args: { + attemptId: string + surface: NestedRepoTelemetrySurface + runtimeKind: NestedRepoTelemetryRuntimeKind + mode: ProjectGroupImportMode + foundCount: number + selectedCount: number + result: ProjectGroupImportResult | null +}): NestedRepoImportResultTelemetry { + const rawFoundCount = normalizeNestedRepoTelemetryCount(args.foundCount) + const rawSelectedCount = normalizeNestedRepoTelemetryCount(args.selectedCount) + const foundCount = capNestedRepoTelemetryCount(args.foundCount) + const selectedCount = capNestedRepoTelemetryCount(args.selectedCount) + const importedCount = capNestedRepoTelemetryCount(args.result?.importedCount ?? 0) + const alreadyKnownCount = capNestedRepoTelemetryCount(args.result?.alreadyKnownCount ?? 0) + const failedCount = capNestedRepoTelemetryCount(args.result?.failedCount ?? selectedCount) + const acceptedCount = importedCount + alreadyKnownCount + const outcome: NestedRepoImportTelemetryOutcome = + acceptedCount === 0 ? 'failed' : failedCount > 0 ? 'partial_failure' : 'success' + + return { + attempt_id: args.attemptId, + surface: args.surface, + runtime_kind: args.runtimeKind, + mode: args.mode, + outcome, + found_count: foundCount, + found_count_bucket: bucketNestedRepoTelemetryCount(foundCount), + selected_count: selectedCount, + selected_count_bucket: bucketNestedRepoTelemetryCount(selectedCount), + imported_count: importedCount, + imported_count_bucket: bucketNestedRepoTelemetryCount(importedCount), + already_known_count: alreadyKnownCount, + already_known_count_bucket: bucketNestedRepoTelemetryCount(alreadyKnownCount), + failed_count: failedCount, + failed_count_bucket: bucketNestedRepoTelemetryCount(failedCount), + all_selected: rawFoundCount > 0 && rawSelectedCount === rawFoundCount + } +} diff --git a/src/shared/telemetry-events.ts b/src/shared/telemetry-events.ts index 3f802752cb9..536056e2ebe 100644 --- a/src/shared/telemetry-events.ts +++ b/src/shared/telemetry-events.ts @@ -18,6 +18,16 @@ import { FEATURE_WALL_MAX_DWELL_MS } from './feature-wall-telemetry' import { FEATURE_WALL_EXIT_ACTIONS, FEATURE_WALL_TOUR_DEPTH_STEPS } from './feature-wall-tour-depth' import { SETUP_SCRIPT_IMPORT_PROVIDERS } from './setup-script-import-providers' import { WORKSPACE_SOURCE_VALUES, type WorkspaceSource } from './workspace-source' +import { + NESTED_REPO_COUNT_BUCKETS, + NESTED_REPO_IMPORT_ACTIONS, + NESTED_REPO_IMPORT_OUTCOMES, + NESTED_REPO_SCAN_RESULTS, + NESTED_REPO_TELEMETRY_MAX_REPO_COUNT, + NESTED_REPO_TELEMETRY_RUNTIME_KINDS, + NESTED_REPO_TELEMETRY_SURFACES, + bucketNestedRepoTelemetryCount +} from './nested-repo-telemetry' import { AGENT_HOOK_TARGETS } from './agent-hook-types' import { ONBOARDING_FINAL_STEP } from './constants' @@ -580,6 +590,103 @@ void _onboardingChecklistItemSyncCheck // `'cohort' in schema.shape`, so there is no parallel hand-maintained list. const cohortSchema = z.enum(['fresh_install', 'upgrade_backfill']).optional() +const nestedRepoTelemetrySurfaceSchema = z.enum(NESTED_REPO_TELEMETRY_SURFACES) +const nestedRepoTelemetryRuntimeKindSchema = z.enum(NESTED_REPO_TELEMETRY_RUNTIME_KINDS) +const nestedRepoCountSchema = z.number().int().min(0).max(NESTED_REPO_TELEMETRY_MAX_REPO_COUNT) +const nestedRepoCountBucketSchema = z.enum(NESTED_REPO_COUNT_BUCKETS) +const nestedRepoScanResultSchema = z.enum(NESTED_REPO_SCAN_RESULTS) +const nestedRepoImportActionSchema = z.enum(NESTED_REPO_IMPORT_ACTIONS) +const nestedRepoImportOutcomeSchema = z.enum(NESTED_REPO_IMPORT_OUTCOMES) +const nestedRepoScanPathKindSchema = z.enum(['git_repo', 'non_git_folder']) +const nestedRepoImportModeSchema = z.enum(['group', 'separate']) +const nestedRepoAttemptIdSchema = z.string().uuid() + +function validateNestedRepoCountBucket( + props: Record, + countKey: string, + bucketKey: string, + ctx: z.RefinementCtx +): void { + const count = props[countKey] + const bucket = props[bucketKey] + if (typeof count !== 'number' || typeof bucket !== 'string') { + return + } + if (bucketNestedRepoTelemetryCount(count) !== bucket) { + ctx.addIssue({ + code: 'custom', + path: [bucketKey], + message: `${bucketKey} must match ${countKey}` + }) + } +} + +function validateNestedRepoCountBuckets( + props: Record, + ctx: z.RefinementCtx +): void { + validateNestedRepoCountBucket(props, 'found_count', 'found_count_bucket', ctx) + validateNestedRepoCountBucket(props, 'selected_count', 'selected_count_bucket', ctx) + validateNestedRepoCountBucket(props, 'imported_count', 'imported_count_bucket', ctx) + validateNestedRepoCountBucket(props, 'already_known_count', 'already_known_count_bucket', ctx) + validateNestedRepoCountBucket(props, 'failed_count', 'failed_count_bucket', ctx) +} + +const nestedRepoTelemetryBaseSchema = { + // Why: high-cardinality by design, but random and non-persistent. It lets + // dashboards count scan -> action -> result attempts without path-derived IDs. + attempt_id: nestedRepoAttemptIdSchema, + surface: nestedRepoTelemetrySurfaceSchema, + runtime_kind: nestedRepoTelemetryRuntimeKindSchema, + nth_repo_added: nthRepoAddedSchema +} as const + +const addRepoNestedScanResultSchema = z + .object({ + ...nestedRepoTelemetryBaseSchema, + result: nestedRepoScanResultSchema, + selected_path_kind: nestedRepoScanPathKindSchema.optional(), + found_count: nestedRepoCountSchema, + found_count_bucket: nestedRepoCountBucketSchema, + truncated: z.boolean(), + timed_out: z.boolean() + }) + .strict() + .superRefine(validateNestedRepoCountBuckets) + +const addRepoNestedImportActionSchema = z + .object({ + ...nestedRepoTelemetryBaseSchema, + action: nestedRepoImportActionSchema, + found_count: nestedRepoCountSchema, + found_count_bucket: nestedRepoCountBucketSchema, + selected_count: nestedRepoCountSchema, + selected_count_bucket: nestedRepoCountBucketSchema, + all_selected: z.boolean() + }) + .strict() + .superRefine(validateNestedRepoCountBuckets) + +const addRepoNestedImportResultSchema = z + .object({ + ...nestedRepoTelemetryBaseSchema, + mode: nestedRepoImportModeSchema, + outcome: nestedRepoImportOutcomeSchema, + found_count: nestedRepoCountSchema, + found_count_bucket: nestedRepoCountBucketSchema, + selected_count: nestedRepoCountSchema, + selected_count_bucket: nestedRepoCountBucketSchema, + imported_count: nestedRepoCountSchema, + imported_count_bucket: nestedRepoCountBucketSchema, + already_known_count: nestedRepoCountSchema, + already_known_count_bucket: nestedRepoCountBucketSchema, + failed_count: nestedRepoCountSchema, + failed_count_bucket: nestedRepoCountBucketSchema, + all_selected: z.boolean() + }) + .strict() + .superRefine(validateNestedRepoCountBuckets) + // `'button' | 'keyboard'` records whether the user advanced via a footer // button click, Cmd/Ctrl+Enter, or an equivalent keyboard exit like Escape. // The uniform shape lets keyboard skip/dismiss paths arrive without a @@ -900,6 +1007,9 @@ export const eventSchemas = { repo_added: repoAddedSchema, add_repo_setup_step_action: addRepoSetupStepActionEventSchema, add_repo_existing_workspaces_detected: addRepoExistingWorkspacesDetectedSchema, + add_repo_nested_scan_result: addRepoNestedScanResultSchema, + add_repo_nested_import_action: addRepoNestedImportActionSchema, + add_repo_nested_import_result: addRepoNestedImportResultSchema, workspace_created: workspaceCreatedSchema, workspace_create_failed: workspaceCreateFailedSchema, setup_script_prompt_shown: setupScriptPromptShownSchema, @@ -988,6 +1098,9 @@ type _CohortExtendedRoster = | 'repo_added' | 'add_repo_setup_step_action' | 'add_repo_existing_workspaces_detected' + | 'add_repo_nested_scan_result' + | 'add_repo_nested_import_action' + | 'add_repo_nested_import_result' | 'workspace_created' | 'workspace_create_failed' | 'setup_script_prompt_shown'