Skip to content

Commit

Permalink
Add feature flag support for gradual rollouts (#2825)
Browse files Browse the repository at this point in the history
### 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.
  • Loading branch information
vinistock authored Nov 5, 2024
1 parent e39ca68 commit 7031842
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-into 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 7031842

Please sign in to comment.