Skip to content

Commit

Permalink
Simplified onboarding experiment assignment and logging (#1036)
Browse files Browse the repository at this point in the history
Users are randomly assigned to get the classic onboarding experience
('control') or a simplified onboarding experience without app setup
('treatment'.) This allocation is hard-coded in the extension; if we
want a different rollout we need to update the extension.

Once we render the login component, we consider the user has been
exposed to a specific arm of the trial and we cache this fact in local
storage.

Currently fixes the rollout at 0.5 so people running main will get
exposed to the experiment. We can bump `SIMPLIFIED_ARM_ALLOCATION` back
to zero if we want to hold back the experiment.

Testers can set a boolean property, `testing.simplified-onboarding`, to
force the extension to display the treatment (true) or control (false)
arm of the trial.

Part of #632 

## Test plan

Automated test:

```
pnpm test:unit # new tests for experiment arm assigment, caching
pnpm test:e2e && pnpm test:integration # hard-coded to use the classic onboarding
# e2e tests for the new login flow TBD
```

Manual test:

1. Cody, ... menu, Sign out, sign out.
2. Control ("Other Sign in Options..." etc.) login should appear.
3. Open the VScode Output pane and change the dropdown to Cody by
Sourcegraph. This log message should appear:

```
logEvent (telemetry disabled): CodyVSCodeExtension:experiment:simplifiedOnboarding:exposed {"arm":"control","excludeFromExperiment":false}
```

4. Verify that login, etc. works.
5. Preferences: Open User Settings JSON. Add a key
`"testing.simplified-onboarding": true`. Reload VScode.
6. New login experience should appear.
7. VScode Output pane, this log message should appear:

```
simplified-onboarding: not logging exposure for testing override selection
``` 

8. Verify login works.
  • Loading branch information
dominiccooney committed Sep 14, 2023
1 parent 650736c commit 4220307
Show file tree
Hide file tree
Showing 12 changed files with 379 additions and 25 deletions.
1 change: 1 addition & 0 deletions vscode/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ Starting from `0.2.0`, Cody is using `major.EVEN_NUMBER.patch` for release versi
- Added a completion statistics summary to the autocomplete trace view. [pull/973](https://github.com/sourcegraph/cody/pull/973)
- Add experimental option `claude-instant-infill` to the `cody.autocomplete.advanced.model` config option that enables users using the Claude Instant model to get suggestions with context awareness (infill). [pull/974](https://github.com/sourcegraph/cody/pull/974)
- New `cody.chat.preInstruction` configuration option for adding custom message at the start of all chat messages sent to Cody. Extension reload required. [pull/963](https://github.com/sourcegraph/cody/pull/963)
- Add a simplified sign-in. 50% of people will see these new sign-in buttons. [pull/1036](https://github.com/sourcegraph/cody/pull/1036)

### Fixed

Expand Down
5 changes: 5 additions & 0 deletions vscode/src/chat/ChatViewProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { ChatMessage, UserLocalHistory } from '@sourcegraph/cody-shared/src/chat
import { View } from '../../webviews/NavBar'
import { logDebug } from '../log'
import { AuthProviderSimplified } from '../services/AuthProviderSimplified'
import * as OnboardingExperiment from '../services/OnboardingExperiment'

import { MessageProvider, MessageProviderOptions } from './MessageProvider'
import { ExtensionMessage, WebviewMessage } from './protocol'
Expand Down Expand Up @@ -72,6 +73,10 @@ export class ChatViewProvider extends MessageProvider implements vscode.WebviewV
void authProviderSimplified.openExternalAuthUrl(this.authProvider, authMethod)
break
}
if (message.type === 'simplified-onboarding-exposure') {
await OnboardingExperiment.logExposure()
break
}
// cody.auth.signin or cody.auth.signout
await vscode.commands.executeCommand(`cody.auth.${message.type}`)
break
Expand Down
13 changes: 2 additions & 11 deletions vscode/src/chat/ContextProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import { logDebug } from '../log'
import { getRerankWithLog } from '../logged-rerank'
import { repositoryRemoteUrl } from '../repository/repositoryHelpers'
import { AuthProvider } from '../services/AuthProvider'
import { OnboardingExperimentArm } from '../services/OnboardingExperiment'
import * as OnboardingExperiment from '../services/OnboardingExperiment'

import { ChatViewProviderWebview } from './ChatViewProvider'
import { GraphContextProvider } from './GraphContextProvider'
Expand Down Expand Up @@ -207,7 +207,7 @@ export class ContextProvider implements vscode.Disposable {
...localProcess,
debugEnable: this.config.debugEnable,
serverEndpoint: this.config.serverEndpoint,
experimentOnboarding: pickOnboardingExperimentArm(),
experimentOnboarding: OnboardingExperiment.pickArm(this.telemetryService),
}

// update codebase context on configuration change
Expand Down Expand Up @@ -289,12 +289,3 @@ export async function getCodebaseContext(
getRerankWithLog(chatClient)
)
}

export function pickOnboardingExperimentArm(): OnboardingExperimentArm {
// TODO(dpc): Actually pick, and cache, experiment arm selection.
// Integrate this cache with user settings, etc. but discourage editing.
const config = vscode.workspace.getConfiguration()
return config.has('cody.todo-internal-onboarding-qa')
? OnboardingExperimentArm.Simplified
: OnboardingExperimentArm.Classic
}
25 changes: 23 additions & 2 deletions vscode/src/chat/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import { CodyLLMSiteConfiguration } from '@sourcegraph/cody-shared/src/sourcegra
import type { TelemetryEventProperties } from '@sourcegraph/cody-shared/src/telemetry'

import { View } from '../../webviews/NavBar'
import { AuthMethod, OnboardingExperimentArm } from '../services/OnboardingExperiment'

/**
* A message sent from the webview to the extension host.
Expand Down Expand Up @@ -35,7 +34,14 @@ export type WebviewMessage =
| { command: 'copy'; eventType: 'Button' | 'Keydown'; text: string }
| {
command: 'auth'
type: 'signin' | 'signout' | 'support' | 'app' | 'callback' | 'simplified-onboarding'
type:
| 'signin'
| 'signout'
| 'support'
| 'app'
| 'callback'
| 'simplified-onboarding'
| 'simplified-onboarding-exposure'
endpoint?: string
value?: string
authMethod?: AuthMethod
Expand Down Expand Up @@ -184,3 +190,18 @@ export function archConvertor(arch: string): string {
}
return arch
}

// Simplified Onboarding types which are shared between WebView and extension.

export type AuthMethod = 'dotcom' | 'github' | 'gitlab' | 'google'

export enum OnboardingExperimentArm {
// Note, these values are persisted to local storage, see pickArm. Do not
// change these values. Adding values is OK but don't delete them.
Classic = 0, // Control
Simplified = 1, // Treatment: simplified onboarding flow

MinValue = Classic,
// Update this when adding an arm to the trial.
MaxValue = Simplified,
}
3 changes: 2 additions & 1 deletion vscode/src/services/AuthProviderSimplified.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@ import * as vscode from 'vscode'

import { DOTCOM_URL } from '@sourcegraph/cody-shared/src/sourcegraph-api/environments'

import { AuthMethod } from '../chat/protocol'

import { AuthProvider } from './AuthProvider'
import { AuthMethod } from './OnboardingExperiment'

// An auth provider for simplified onboarding. This is a sidecar to AuthProvider
// so we can deprecate the experiment later. AuthProviderSimplified only works
Expand Down
159 changes: 159 additions & 0 deletions vscode/src/services/OnboardingExperiment.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import * as vscode from 'vscode'

import { OnboardingExperimentArm } from '../chat/protocol'

import { localStorage } from './LocalStorageProvider'
import * as OnboardingExperiment from './OnboardingExperiment'

vi.mock('vscode', mockVScode)
vi.mock('./LocalStorageProvider', mockLocalStorage)
vi.mock('../log', mockLog)

function mockVScode() {
return {
UIKind: {
Desktop: 1,
Web: 42,
},
env: {
uiKind: 1, // Desktop
},
workspace: {
getConfiguration: () => ({
get: () => undefined,
}),
},
}
}

function mockLog() {
return {
logDebug: () => {},
}
}

function mockLocalStorage() {
return {
localStorage: {
get: () => null,
set: () => {},
},
}
}

describe('OnboardingExperiment', () => {
const mockTelemetry = {
log: vi.fn(),
}

beforeEach(() => {
OnboardingExperiment.resetForTesting()
})

afterEach(() => {
vi.restoreAllMocks()
})

it('caches arms on exposure, not when picking them', async () => {
vi.spyOn(global.Math, 'random').mockReturnValueOnce(2)
const set = vi.spyOn(localStorage, 'set')
OnboardingExperiment.pickArm(mockTelemetry)
expect(set).not.toHaveBeenCalled()
await OnboardingExperiment.logExposure()
expect(set).toHaveBeenCalledWith('experiment.onboarding', '{"arm":0,"excludeFromExperiment":false}')
})

it('randomly assigns arms', () => {
// A number less than zero will always trigger the experiment.
const random = vi.spyOn(global.Math, 'random').mockReturnValueOnce(-1)
expect(OnboardingExperiment.pickArm(mockTelemetry)).toBe(OnboardingExperimentArm.Simplified)
expect(random).toBeCalled()

OnboardingExperiment.resetForTesting()
// A number greater than 1 will always trigger the control arm of the trial.
random.mockReturnValueOnce(2)
expect(OnboardingExperiment.pickArm(mockTelemetry)).toBe(OnboardingExperimentArm.Classic)
})

it('caches the arm in memory once picked', () => {
const localStorageGet = vi.spyOn(localStorage, 'get')
const random = vi.spyOn(global.Math, 'random')
const arm = OnboardingExperiment.pickArm(mockTelemetry)
expect(localStorageGet).toBeCalled()
expect(random).toBeCalled()

localStorageGet.mockReset()
random.mockReset()
expect(OnboardingExperiment.pickArm(mockTelemetry)).toBe(arm)
expect(localStorageGet).not.toBeCalled()
expect(random).not.toBeCalled()
})

it('logs exposures', async () => {
vi.spyOn(global.Math, 'random').mockReturnValueOnce(-1)
expect(OnboardingExperiment.pickArm(mockTelemetry)).toBe(OnboardingExperimentArm.Simplified)
const log = vi.spyOn(mockTelemetry, 'log')
await OnboardingExperiment.logExposure()
expect(log).toHaveBeenCalledWith('CodyVSCodeExtension:experiment:simplifiedOnboarding:exposed', {
arm: 'treatment',
excludeFromExperiment: false,
})
})

it('defers to arms cached in local storage', async () => {
const localStorageGet = vi.spyOn(localStorage, 'get').mockReturnValue('{"arm":0,"excludeFromExperiment":true}')
const random = vi.spyOn(global.Math, 'random')
expect(OnboardingExperiment.pickArm(mockTelemetry)).toBe(OnboardingExperimentArm.Classic)
expect(localStorageGet).toBeCalled()
expect(random).not.toBeCalled()

const log = vi.spyOn(mockTelemetry, 'log')
await OnboardingExperiment.logExposure()
expect(log).toHaveBeenCalledWith('CodyVSCodeExtension:experiment:simplifiedOnboarding:exposed', {
arm: 'control',
excludeFromExperiment: true,
})
})

it('can be overridden ON with a config parameter', async () => {
vi.spyOn(vscode.workspace, 'getConfiguration').mockReturnValue({
get: (key: string) => key === 'testing.simplified-onboarding',
} as unknown as vscode.WorkspaceConfiguration)
const localStorageGet = vi.spyOn(localStorage, 'get')
const random = vi.spyOn(global.Math, 'random')
expect(OnboardingExperiment.pickArm(mockTelemetry)).toBe(OnboardingExperimentArm.Simplified)
expect(localStorageGet).not.toBeCalled()
expect(random).not.toBeCalled()

const log = vi.spyOn(mockTelemetry, 'log')
await OnboardingExperiment.logExposure()
expect(log).not.toBeCalled()
})

it('can be overridden OFF with a config parameter', async () => {
vi.spyOn(vscode.workspace, 'getConfiguration').mockReturnValue({
get: () => false,
} as unknown as vscode.WorkspaceConfiguration)
const localStorageGet = vi.spyOn(localStorage, 'get')
const random = vi.spyOn(global.Math, 'random')
expect(OnboardingExperiment.pickArm(mockTelemetry)).toBe(OnboardingExperimentArm.Classic)
expect(localStorageGet).not.toBeCalled()
expect(random).not.toBeCalled()

const log = vi.spyOn(mockTelemetry, 'log')
await OnboardingExperiment.logExposure()
expect(log).not.toBeCalled()
})

it('does not log exposures from overrides', async () => {
vi.spyOn(vscode.workspace, 'getConfiguration').mockReturnValue({
get: (key: string) => key === 'testing.simplified-onboarding',
} as unknown as vscode.WorkspaceConfiguration)
expect(OnboardingExperiment.pickArm(mockTelemetry)).toBe(OnboardingExperimentArm.Simplified)

const log = vi.spyOn(mockTelemetry, 'log')
await OnboardingExperiment.logExposure()
expect(log).not.toBeCalled()
})
})
Loading

0 comments on commit 4220307

Please sign in to comment.