diff --git a/vscode/package.json b/vscode/package.json index 368d434dd..5a78d76e9 100644 --- a/vscode/package.json +++ b/vscode/package.json @@ -478,6 +478,21 @@ "description": "[EXPERIMENTAL] Uses server launcher for gracefully handling missing dependencies.", "type": "boolean", "default": false + }, + "rubyLsp.featureFlags": { + "description": "Allows opting in or out of feature flags", + "type": "object", + "properties": { + "all": { + "description": "Opt-into all available feature flags", + "type": "boolean" + }, + "tapiocaAddon": { + "description": "Opt-in/out of the Tapioca add-on", + "type": "boolean" + } + }, + "default": {} } } }, diff --git a/vscode/src/common.ts b/vscode/src/common.ts index 08d2e7b4f..61787bb06 100644 --- a/vscode/src/common.ts +++ b/vscode/src/common.ts @@ -1,4 +1,5 @@ import { exec } from "child_process"; +import { createHash } from "crypto"; import { promisify } from "util"; import * as vscode from "vscode"; @@ -74,6 +75,15 @@ export const LOG_CHANNEL = vscode.window.createOutputChannel(LSP_NAME, { }); export const SUPPORTED_LANGUAGE_IDS = ["ruby", "erb"]; +// A list of feature flags where the key is the name and the value is the rollout percentage. +// +// Note: names added here should also be added to the `rubyLsp.optedOutFeatureFlags` enum in the `package.json` file +export const FEATURE_FLAGS = { + tapiocaAddon: 0.0, +}; + +type FeatureFlagConfigurationKey = keyof typeof FEATURE_FLAGS | "all"; + // Creates a debounced version of a function with the specified delay. If the function is invoked before the delay runs // out, then the previous invocation of the function gets cancelled and a new one is scheduled. // @@ -99,3 +109,37 @@ export function debounce(fn: (...args: any[]) => Promise, delay: number) { }); }; } + +// Check if the given feature is enabled for the current user given the configured rollout percentage +export function featureEnabled(feature: keyof typeof FEATURE_FLAGS): boolean { + const flagConfiguration = vscode.workspace + .getConfiguration("rubyLsp") + .get< + Record + >("featureFlags")!; + + // If the user opted out of this feature, return false. We explicitly check for `false` because `undefined` means + // nothing was configured + if (flagConfiguration[feature] === false || flagConfiguration.all === false) { + return false; + } + + // If the user opted-in to all features, return true + if (flagConfiguration.all) { + return true; + } + + const percentage = FEATURE_FLAGS[feature]; + const machineId = vscode.env.machineId; + // Create a digest of the concatenated machine ID and feature name, which will generate a unique hash for this + // user-feature combination + const hash = createHash("sha256") + .update(`${machineId}-${feature}`) + .digest("hex"); + + // Convert the first 8 characters of the hash to a number between 0 and 1 + const hashNum = parseInt(hash.substring(0, 8), 16) / 0xffffffff; + + // If that number is below the percentage, then the feature is enabled for this user + return hashNum < percentage; +} diff --git a/vscode/src/test/suite/common.test.ts b/vscode/src/test/suite/common.test.ts new file mode 100644 index 000000000..cb1e99bdc --- /dev/null +++ b/vscode/src/test/suite/common.test.ts @@ -0,0 +1,91 @@ +import * as assert from "assert"; + +import * as vscode from "vscode"; +import sinon from "sinon"; + +import { featureEnabled, FEATURE_FLAGS } from "../../common"; + +suite("Common", () => { + let sandbox: sinon.SinonSandbox; + + setup(() => { + sandbox = sinon.createSandbox(); + const number = 42; + sandbox.stub(vscode.env, "machineId").value(number.toString(16)); + }); + + teardown(() => { + sandbox.restore(); + }); + + test("returns consistent results for the same rollout percentage", () => { + const firstCall = featureEnabled("tapiocaAddon"); + + for (let i = 0; i < 50; i++) { + const result = featureEnabled("tapiocaAddon"); + + assert.strictEqual( + firstCall, + result, + "Feature flag should be deterministic", + ); + } + }); + + test("maintains enabled state when increasing rollout percentage", () => { + // For the fake machine of 42 in base 16 and the name `fakeFeature`, the feature flag activation percetange is + // 0.357. For every percetange below that, the feature should appear as disabled + [0.25, 0.3, 0.35].forEach((percentage) => { + (FEATURE_FLAGS as any).fakeFeature = percentage; + assert.strictEqual(featureEnabled("fakeFeature" as any), false); + }); + + // And for every percentage above that, the feature should appear as enabled + [0.36, 0.45, 0.55, 0.65, 0.75, 0.85, 0.9, 1].forEach((percentage) => { + (FEATURE_FLAGS as any).fakeFeature = percentage; + assert.strictEqual(featureEnabled("fakeFeature" as any), true); + }); + }); + + test("returns false if user opted out of specific feature", () => { + (FEATURE_FLAGS as any).fakeFeature = 1; + + const stub = sandbox.stub(vscode.workspace, "getConfiguration").returns({ + get: () => { + return { fakeFeature: false }; + }, + } as any); + + const result = featureEnabled("fakeFeature" as any); + stub.restore(); + assert.strictEqual(result, false); + }); + + test("returns false if user opted out of all features", () => { + (FEATURE_FLAGS as any).fakeFeature = 1; + + const stub = sandbox.stub(vscode.workspace, "getConfiguration").returns({ + get: () => { + return { all: false }; + }, + } as any); + + const result = featureEnabled("fakeFeature" as any); + stub.restore(); + assert.strictEqual(result, false); + }); + + test("returns true if user opted in to all features", () => { + (FEATURE_FLAGS as any).fakeFeature = 0.02; + + const stub = sandbox.stub(vscode.workspace, "getConfiguration").returns({ + get: () => { + return { all: true }; + }, + } as any); + + const result = featureEnabled("fakeFeature" as any); + stub.restore(); + assert.strictEqual(result, true); + }); +});