mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
Track nested repo import telemetry (#3006)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user