packages/core/src/shared/featureConfig.ts (200 lines of code) (raw):
/*!
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
* SPDX-License-Identifier: Apache-2.0
*/
import {
Customization,
FeatureValue,
ListFeatureEvaluationsRequest,
ListFeatureEvaluationsResponse,
} from '../codewhisperer/client/codewhispereruserclient'
import * as vscode from 'vscode'
import * as nls from 'vscode-nls'
import { codeWhispererClient as client } from '../codewhisperer/client/codewhisperer'
import { AuthUtil } from '../codewhisperer/util/authUtil'
import { getLogger } from './logger/logger'
import { isBuilderIdConnection, isIdcSsoConnection } from '../auth/connection'
import { CodeWhispererSettings } from '../codewhisperer/util/codewhispererSettings'
import globals from './extensionGlobals'
import { getClientId, getOperatingSystem } from './telemetry/util'
import { extensionVersion } from './vscode/env'
import { telemetry } from './telemetry/telemetry'
import { Commands } from './vscode/commands2'
import { setSelectedCustomization } from '../codewhisperer/util/customizationUtil'
const localize = nls.loadMessageBundle()
export class FeatureContext {
constructor(
public name: string,
public variation: string,
public value: FeatureValue
) {}
}
const featureConfigPollIntervalInMs = 180 * 60 * 1000 // 180 mins
export const Features = {
customizationArnOverride: 'customizationArnOverride',
dataCollectionFeature: 'IDEProjectContextDataCollection',
projectContextFeature: 'ProjectContextV2',
workspaceContextFeature: 'WorkspaceContext',
test: 'testFeature',
highlightCommand: 'highlightCommand',
} as const
export type FeatureName = (typeof Features)[keyof typeof Features]
export const featureDefinitions = new Map<FeatureName, FeatureContext>([
[Features.test, new FeatureContext(Features.test, 'CONTROL', { stringValue: 'testValue' })],
[
Features.customizationArnOverride,
new FeatureContext(Features.customizationArnOverride, 'customizationARN', { stringValue: '' }),
],
])
export class FeatureConfigProvider {
private featureConfigs = new Map<string, FeatureContext>()
static #instance: FeatureConfigProvider
constructor() {
this.fetchFeatureConfigs().catch((e) => {
getLogger().error('fetchFeatureConfigs failed: %s', (e as Error).message)
})
setInterval(this.fetchFeatureConfigs.bind(this), featureConfigPollIntervalInMs)
}
public static get instance() {
return (this.#instance ??= new this())
}
getProjectContextGroup(): 'control' | 't1' | 't2' {
const variation = this.featureConfigs.get(Features.projectContextFeature)?.variation
switch (variation) {
case 'CONTROL':
return 'control'
case 'TREATMENT_1':
return 't1'
case 'TREATMENT_2':
return 't2'
default:
return 'control'
}
}
getWorkspaceContextGroup(): 'control' | 'treatment' {
const variation = this.featureConfigs.get(Features.projectContextFeature)?.variation
switch (variation) {
case 'CONTROL':
return 'control'
case 'TREATMENT':
return 'treatment'
default:
return 'control'
}
}
public async listFeatureEvaluations(): Promise<ListFeatureEvaluationsResponse> {
const profile = AuthUtil.instance.regionProfileManager.activeRegionProfile
const request: ListFeatureEvaluationsRequest = {
userContext: {
ideCategory: 'VSCODE',
operatingSystem: getOperatingSystem(),
product: 'CodeWhisperer', // TODO: update this?
clientId: getClientId(globals.globalState),
ideVersion: extensionVersion,
},
profileArn: profile?.arn,
}
return (await client.createUserSdkClient()).listFeatureEvaluations(request).promise()
}
async fetchFeatureConfigs(): Promise<void> {
if (AuthUtil.instance.isConnectionExpired()) {
return
}
getLogger().debug('amazonq: Fetching feature configs')
try {
const response = await this.listFeatureEvaluations()
// Overwrite feature configs from server response
for (const evaluation of response.featureEvaluations) {
this.featureConfigs.set(
evaluation.feature,
new FeatureContext(evaluation.feature, evaluation.variation, evaluation.value)
)
telemetry.aws_featureConfig.run((span) => {
span.record({
id: evaluation.feature,
featureVariation: evaluation.variation,
featureValue: JSON.stringify(evaluation.value),
})
})
}
getLogger().info('AB Testing Cohort Assignments %O', response.featureEvaluations)
const customizationArnOverride = this.featureConfigs.get(Features.customizationArnOverride)?.value
?.stringValue
const previousOverride = globals.globalState.tryGet<string>('aws.amazonq.customization.overrideV2', String)
if (customizationArnOverride !== undefined && customizationArnOverride !== previousOverride) {
// Double check if server-side wrongly returns a customizationArn to BID users
if (isBuilderIdConnection(AuthUtil.instance.conn)) {
this.featureConfigs.delete(Features.customizationArnOverride)
} else if (isIdcSsoConnection(AuthUtil.instance.conn)) {
let availableCustomizations: Customization[] = []
try {
const items: Customization[] = []
const response = await client.listAvailableCustomizations()
for (const customizations of response.map(
(listAvailableCustomizationsResponse) => listAvailableCustomizationsResponse.customizations
)) {
items.push(...customizations)
}
availableCustomizations = items
} catch (e) {
getLogger().debug('amazonq: Failed to list available customizations')
}
// If customizationArn from A/B is not available in listAvailableCustomizations response, don't use this value
const targetCustomization = availableCustomizations?.find((c) => c.arn === customizationArnOverride)
if (!targetCustomization) {
getLogger().debug(
`Customization arn ${customizationArnOverride} not available in listAvailableCustomizations, not using`
)
this.featureConfigs.delete(Features.customizationArnOverride)
} else {
await setSelectedCustomization(targetCustomization, true)
}
await vscode.commands.executeCommand('aws.amazonq.refreshStatusBar')
}
}
if (this.getWorkspaceContextGroup() === 'treatment') {
// Enable local workspace index by default only once, for Amzn users.
const isSet = globals.globalState.get<boolean>('aws.amazonq.workspaceIndexToggleOn') || false
if (!isSet) {
await CodeWhispererSettings.instance.enableLocalIndex()
globals.globalState.tryUpdate('aws.amazonq.workspaceIndexToggleOn', true)
await vscode.window
.showInformationMessage(
localize(
'AWS.amazonq.chat.workspacecontext.enable.message',
'Amazon Q: Workspace index is now enabled. You can disable it from Amazon Q settings.'
),
localize('AWS.amazonq.opensettings', 'Open settings')
)
.then((r) => {
if (r === 'Open settings') {
void Commands.tryExecute('aws.amazonq.configure').then()
}
})
}
}
} catch (e) {
getLogger().error(`CodeWhisperer: Error when fetching feature configs ${e}`, e)
}
getLogger().debug(`CodeWhisperer: Current feature configs: ${this.getFeatureConfigsTelemetry()}`)
}
// Sample format: "{testFeature: CONTROL}""
getFeatureConfigsTelemetry(): string {
return `{${Array.from(this.featureConfigs.entries())
.map(([name, context]) => `${name}: ${context.variation}`)
.join(', ')}}`
}
// TODO: for all feature variations, define a contract that can be enforced upon the implementation of
// the business logic.
// When we align on a new feature config, client-side will implement specific business logic to utilize
// these values by:
// 1) Add an entry in featureDefinitions, which is <feature_name> to <feature_context>.
// 2) Add a function with name `getXXX`, where XXX refers to the feature name.
// 3) Specify the return type: One of the return type string/boolean/Long/Double should be used here.
// 4) Specify the key for the `getFeatureValueForKey` helper function which is the feature name.
// 5) Specify the corresponding type value getter for the `FeatureValue` class. For example,
// if the return type is Long, then the corresponding type value getter is `longValue()`.
// 6) Add a test case for this feature.
// 7) In case `getXXX()` returns undefined, it should be treated as a default/control group.
getTestFeature(): string | undefined {
return this.getFeatureValueForKey(Features.test).stringValue
}
getCustomizationArnOverride(): string | undefined {
return this.getFeatureValueForKey(Features.customizationArnOverride).stringValue
}
// Get the feature value for the given key.
// In case of a misconfiguration, it will return a default feature value of Boolean true.
private getFeatureValueForKey(name: FeatureName): FeatureValue {
return this.featureConfigs.get(name)?.value ?? featureDefinitions.get(name)?.value ?? { boolValue: true }
}
/**
* Map of feature configurations.
*
* @returns {Map<string, FeatureContext>} A Map containing the feature configurations, where the keys are strings representing the feature names, and the values are FeatureContext objects.
*/
public static getFeatureConfigs(): Map<string, FeatureContext> {
return FeatureConfigProvider.instance.featureConfigs
}
/**
* Retrieves the FeatureContext object for a given feature name.
*
* @param {string} featureName - The name of the feature.
* @returns {FeatureContext | undefined} The FeatureContext object for the specified feature, or undefined if the feature doesn't exist.
*/
public static getFeature(featureName: FeatureName): FeatureContext | undefined {
return FeatureConfigProvider.instance.featureConfigs.get(featureName)
}
/**
* Checks if a feature is active or not.
*
* @param {string} featureName - The name of the feature to check.
* @returns {boolean} False if the variation is not CONTROL, otherwise True
*/
public static isEnabled(featureName: FeatureName): boolean {
const featureContext = FeatureConfigProvider.getFeature(featureName)
if (featureContext && featureContext.variation.toLocaleLowerCase() !== 'control') {
return true
}
return false
}
}