Skip to content

Commit 7031842

Browse files
authored
Add feature flag support for gradual rollouts (#2825)
### Motivation Add the ability to rollout features to users based on percentages. This PR will essentially replace our experimental features configuration with a much more granular and controlled rolled out strategy. Note: I'll leave removing the old experimental features setting to a future PR. ### Implementation The idea is to combine the user's machine ID and the feature flag name to generate a number that can be checked against the desired rollout percentage. With this strategy, we can guarantee that if we increase the percentage, users who already had the feature enabled will continue to do so. From our side, the idea is to maintain a constant with the feature names and the desired rollout, so that we can gradually increase it as needed. ### Automated Tests Added tests.
1 parent e39ca68 commit 7031842

File tree

3 files changed

+150
-0
lines changed

3 files changed

+150
-0
lines changed

vscode/package.json

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -478,6 +478,21 @@
478478
"description": "[EXPERIMENTAL] Uses server launcher for gracefully handling missing dependencies.",
479479
"type": "boolean",
480480
"default": false
481+
},
482+
"rubyLsp.featureFlags": {
483+
"description": "Allows opting in or out of feature flags",
484+
"type": "object",
485+
"properties": {
486+
"all": {
487+
"description": "Opt-into all available feature flags",
488+
"type": "boolean"
489+
},
490+
"tapiocaAddon": {
491+
"description": "Opt-in/out of the Tapioca add-on",
492+
"type": "boolean"
493+
}
494+
},
495+
"default": {}
481496
}
482497
}
483498
},

vscode/src/common.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { exec } from "child_process";
2+
import { createHash } from "crypto";
23
import { promisify } from "util";
34

45
import * as vscode from "vscode";
@@ -74,6 +75,15 @@ export const LOG_CHANNEL = vscode.window.createOutputChannel(LSP_NAME, {
7475
});
7576
export const SUPPORTED_LANGUAGE_IDS = ["ruby", "erb"];
7677

78+
// A list of feature flags where the key is the name and the value is the rollout percentage.
79+
//
80+
// Note: names added here should also be added to the `rubyLsp.optedOutFeatureFlags` enum in the `package.json` file
81+
export const FEATURE_FLAGS = {
82+
tapiocaAddon: 0.0,
83+
};
84+
85+
type FeatureFlagConfigurationKey = keyof typeof FEATURE_FLAGS | "all";
86+
7787
// Creates a debounced version of a function with the specified delay. If the function is invoked before the delay runs
7888
// out, then the previous invocation of the function gets cancelled and a new one is scheduled.
7989
//
@@ -99,3 +109,37 @@ export function debounce(fn: (...args: any[]) => Promise<void>, delay: number) {
99109
});
100110
};
101111
}
112+
113+
// Check if the given feature is enabled for the current user given the configured rollout percentage
114+
export function featureEnabled(feature: keyof typeof FEATURE_FLAGS): boolean {
115+
const flagConfiguration = vscode.workspace
116+
.getConfiguration("rubyLsp")
117+
.get<
118+
Record<FeatureFlagConfigurationKey, boolean | undefined>
119+
>("featureFlags")!;
120+
121+
// If the user opted out of this feature, return false. We explicitly check for `false` because `undefined` means
122+
// nothing was configured
123+
if (flagConfiguration[feature] === false || flagConfiguration.all === false) {
124+
return false;
125+
}
126+
127+
// If the user opted-in to all features, return true
128+
if (flagConfiguration.all) {
129+
return true;
130+
}
131+
132+
const percentage = FEATURE_FLAGS[feature];
133+
const machineId = vscode.env.machineId;
134+
// Create a digest of the concatenated machine ID and feature name, which will generate a unique hash for this
135+
// user-feature combination
136+
const hash = createHash("sha256")
137+
.update(`${machineId}-${feature}`)
138+
.digest("hex");
139+
140+
// Convert the first 8 characters of the hash to a number between 0 and 1
141+
const hashNum = parseInt(hash.substring(0, 8), 16) / 0xffffffff;
142+
143+
// If that number is below the percentage, then the feature is enabled for this user
144+
return hashNum < percentage;
145+
}

vscode/src/test/suite/common.test.ts

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import * as assert from "assert";
2+
3+
import * as vscode from "vscode";
4+
import sinon from "sinon";
5+
6+
import { featureEnabled, FEATURE_FLAGS } from "../../common";
7+
8+
suite("Common", () => {
9+
let sandbox: sinon.SinonSandbox;
10+
11+
setup(() => {
12+
sandbox = sinon.createSandbox();
13+
const number = 42;
14+
sandbox.stub(vscode.env, "machineId").value(number.toString(16));
15+
});
16+
17+
teardown(() => {
18+
sandbox.restore();
19+
});
20+
21+
test("returns consistent results for the same rollout percentage", () => {
22+
const firstCall = featureEnabled("tapiocaAddon");
23+
24+
for (let i = 0; i < 50; i++) {
25+
const result = featureEnabled("tapiocaAddon");
26+
27+
assert.strictEqual(
28+
firstCall,
29+
result,
30+
"Feature flag should be deterministic",
31+
);
32+
}
33+
});
34+
35+
test("maintains enabled state when increasing rollout percentage", () => {
36+
// For the fake machine of 42 in base 16 and the name `fakeFeature`, the feature flag activation percetange is
37+
// 0.357. For every percetange below that, the feature should appear as disabled
38+
[0.25, 0.3, 0.35].forEach((percentage) => {
39+
(FEATURE_FLAGS as any).fakeFeature = percentage;
40+
assert.strictEqual(featureEnabled("fakeFeature" as any), false);
41+
});
42+
43+
// And for every percentage above that, the feature should appear as enabled
44+
[0.36, 0.45, 0.55, 0.65, 0.75, 0.85, 0.9, 1].forEach((percentage) => {
45+
(FEATURE_FLAGS as any).fakeFeature = percentage;
46+
assert.strictEqual(featureEnabled("fakeFeature" as any), true);
47+
});
48+
});
49+
50+
test("returns false if user opted out of specific feature", () => {
51+
(FEATURE_FLAGS as any).fakeFeature = 1;
52+
53+
const stub = sandbox.stub(vscode.workspace, "getConfiguration").returns({
54+
get: () => {
55+
return { fakeFeature: false };
56+
},
57+
} as any);
58+
59+
const result = featureEnabled("fakeFeature" as any);
60+
stub.restore();
61+
assert.strictEqual(result, false);
62+
});
63+
64+
test("returns false if user opted out of all features", () => {
65+
(FEATURE_FLAGS as any).fakeFeature = 1;
66+
67+
const stub = sandbox.stub(vscode.workspace, "getConfiguration").returns({
68+
get: () => {
69+
return { all: false };
70+
},
71+
} as any);
72+
73+
const result = featureEnabled("fakeFeature" as any);
74+
stub.restore();
75+
assert.strictEqual(result, false);
76+
});
77+
78+
test("returns true if user opted in to all features", () => {
79+
(FEATURE_FLAGS as any).fakeFeature = 0.02;
80+
81+
const stub = sandbox.stub(vscode.workspace, "getConfiguration").returns({
82+
get: () => {
83+
return { all: true };
84+
},
85+
} as any);
86+
87+
const result = featureEnabled("fakeFeature" as any);
88+
stub.restore();
89+
assert.strictEqual(result, true);
90+
});
91+
});

0 commit comments

Comments
 (0)