Skip to content

Commit

Permalink
Add the ability to rollout features to a percetange of users
Browse files Browse the repository at this point in the history
  • Loading branch information
vinistock committed Nov 5, 2024
1 parent d4064db commit 547631f
Show file tree
Hide file tree
Showing 3 changed files with 150 additions and 0 deletions.
15 changes: 15 additions & 0 deletions vscode/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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-in/out of all available feature flags",
"type": "boolean"
},
"tapiocaAddon": {
"description": "Opt-in/out of the Tapioca add-on",
"type": "boolean"
}
},
"default": {}
}
}
},
Expand Down
44 changes: 44 additions & 0 deletions vscode/src/common.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { exec } from "child_process";
import { createHash } from "crypto";
import { promisify } from "util";

import * as vscode from "vscode";
Expand Down Expand Up @@ -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.
//
Expand All @@ -99,3 +109,37 @@ export function debounce(fn: (...args: any[]) => Promise<void>, 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<FeatureFlagConfigurationKey, boolean | undefined>
>("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;
}
91 changes: 91 additions & 0 deletions vscode/src/test/suite/common.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});

0 comments on commit 547631f

Please sign in to comment.