Track nested repo import telemetry (#3006)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Brennan Benson
2026-05-28 13:38:46 -07:00
committed by GitHub
co-authored by Orca
parent 83b97d52a1
commit a68f0d20f7
9 changed files with 1081 additions and 28 deletions
@@ -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}
@@ -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)
})
})
@@ -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<NestedRepoScanResult | null>(null)
const [nestedSelectedPaths, setNestedSelectedPaths] = useState<Set<string>>(new Set())
const [nestedGroupName, setNestedGroupName] = useState('')
const [nestedAttemptId, setNestedAttemptId] = useState<string | null>(null)
const [nestedRuntimeKind, setNestedRuntimeKind] = useState<NestedRepoTelemetryRuntimeKind | null>(
null
)
const [tourStarted, setTourStarted] = useState(false)
const [busyLabel, setBusyLabel] = useState<string | null>(null)
const [error, setError] = useState<string | null>(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,
@@ -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<Set<string>>(new Set())
const [nestedGroupName, setNestedGroupName] = useState('')
const [nestedConnectionId, setNestedConnectionId] = useState<string | null>(null)
const [nestedAttemptId, setNestedAttemptId] = useState<string | null>(null)
const [nestedRuntimeKind, setNestedRuntimeKind] = useState<NestedRepoTelemetryRuntimeKind | null>(
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()
}
@@ -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<string | null>(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,
@@ -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)
})
})
+221
View File
@@ -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)
})
})
+228
View File
@@ -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
}
}
+113
View File
@@ -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<string, unknown>,
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<string, unknown>,
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'