Skip to content

[dashboard] ConfigCat experiment #10048

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
May 17, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions components/dashboard/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"private": true,
"dependencies": {
"@gitpod/gitpod-protocol": "0.1.5",
"configcat-js": "^5.7.2",
"countries-list": "^2.6.1",
"js-cookie": "^3.0.1",
"moment": "^2.29.1",
Expand Down
24 changes: 24 additions & 0 deletions components/dashboard/src/experiments/always-default.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/**
* Copyright (c) 2022 Gitpod GmbH. All rights reserved.
* Licensed under the GNU Affero General Public License (AGPL).
* See License-AGPL.txt in the project root for license information.
*/
import { Attributes, Client } from "./client";

// AlwaysReturningDefaultValueClient is an implemention of an experiments.Client which performs no lookup/network operation
// and always returns the default value for a given experimentName.
// This client is used for non-SaaS version of Gitpod, in particular for self-hosted installations where external
// network connections are not desirable or even possible.
class AlwaysReturningDefaultValueClient implements Client {
getValueAsync<T>(experimentName: string, defaultValue: T, attributes: Attributes): Promise<T> {
return Promise.resolve(defaultValue);
}

dispose(): void {
// there is nothing to dispose, no-op.
}
}

export function newAlwaysReturningDefaultValueClient(): Client {
return new AlwaysReturningDefaultValueClient();
}
55 changes: 55 additions & 0 deletions components/dashboard/src/experiments/client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
/**
* Copyright (c) 2022 Gitpod GmbH. All rights reserved.
* Licensed under the GNU Affero General Public License (AGPL).
* See License-AGPL.txt in the project root for license information.
*/

// Attributes define attributes which can be used to segment audiences.
// Set the attributes which you want to use to group audiences into.
import { newNonProductionConfigCatClient, newProductionConfigCatClient } from "./configcat";
import { newAlwaysReturningDefaultValueClient } from "./always-default";

export interface Attributes {
userID?: string;
email?: string;

// Gitpod Project ID
projectID?: string;

// Gitpod Team ID
teamID?: string;
// Gitpod Team Name
teamName?: string;
}

export interface Client {
getValueAsync<T>(experimentName: string, defaultValue: T, attributes: Attributes): Promise<T>;

// dispose will dispose of the client, no longer retrieving flags
dispose(): void;
}

let client: Client | undefined;

export function getExperimentsClient(): Client {
// We have already instantiated a client, we can just re-use it.
if (client !== undefined) {
return client;
}

const host = window.location.hostname;
if (host === "gitpod.io") {
client = newProductionConfigCatClient();
} else if (host === "gitpod-staging.com" || host.endsWith("gitpod-dev.com") || host.endsWith("gitpod-io-dev.com")) {
client = newNonProductionConfigCatClient();
} else {
// We're gonna use a client which always returns the default value.
client = newAlwaysReturningDefaultValueClient();
}

return client;
}

export const PROJECT_ID_ATTRIBUTE = "project_id";
export const TEAM_ID_ATTRIBUTE = "team_id";
export const TEAM_NAME_ATTRIBUTE = "team_name";
69 changes: 69 additions & 0 deletions components/dashboard/src/experiments/configcat.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
/**
* Copyright (c) 2022 Gitpod GmbH. All rights reserved.
* Licensed under the GNU Affero General Public License (AGPL).
* See License-AGPL.txt in the project root for license information.
*/

import * as configcat from "configcat-js";
import { IConfigCatClient } from "configcat-common/lib/ConfigCatClient";
import { User } from "configcat-common/lib/RolloutEvaluator";
import { Attributes, Client, PROJECT_ID_ATTRIBUTE, TEAM_ID_ATTRIBUTE, TEAM_NAME_ATTRIBUTE } from "./client";

// newProductionConfigCatClient constructs a new ConfigCat client with production configuration.
// DO NOT USE DIRECTLY! Use getExperimentsClient() instead.
export function newProductionConfigCatClient(): Client {
// clientKey is an identifier of our ConfigCat application. It is not a secret.
const clientKey = "WBLaCPtkjkqKHlHedziE9g/TwAe6YyftEGPnGxVRXd0Ig";
const client = configcat.createClient(clientKey, {
logger: configcat.createConsoleLogger(2),
});

return new ConfigCatClient(client);
}

// newNonProductionConfigCatClient constructs a new ConfigCat client with non-production configuration.
// DO NOT USE DIRECTLY! Use getExperimentsClient() instead.
export function newNonProductionConfigCatClient(): Client {
// clientKey is an identifier of our ConfigCat application. It is not a secret.
const clientKey = "WBLaCPtkjkqKHlHedziE9g/LEAOCNkbuUKiqUZAcVg7dw";
const client = configcat.createClient(clientKey, {
pollIntervalSeconds: 60 * 3, // 3 minutes
logger: configcat.createConsoleLogger(3),
});

return new ConfigCatClient(client);
}

class ConfigCatClient implements Client {
private client: IConfigCatClient;

constructor(cc: IConfigCatClient) {
this.client = cc;
}

getValueAsync<T>(experimentName: string, defaultValue: T, attributes: Attributes): Promise<T> {
return this.client.getValueAsync(experimentName, defaultValue, attributesToUser(attributes));
}

dispose(): void {
return this.client.dispose();
}
}

function attributesToUser(attributes: Attributes): User {
const userID = attributes.userID || "";
const email = attributes.email || "";

const custom: { [key: string]: string } = {};
if (attributes.projectID) {
custom[PROJECT_ID_ATTRIBUTE] = attributes.projectID;
}
if (attributes.teamID) {
custom[TEAM_ID_ATTRIBUTE] = attributes.teamID;
}
if (attributes.teamName) {
custom[TEAM_NAME_ATTRIBUTE] = attributes.teamName;
}

return new User(userID, email, "", custom);
}
15 changes: 13 additions & 2 deletions components/dashboard/src/workspaces/Workspaces.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { User } from "@gitpod/gitpod-protocol";
import { useLocation } from "react-router";
import { StartWorkspaceModalContext, StartWorkspaceModalKeyBinding } from "./start-workspace-modal-context";
import SelectIDEModal from "../settings/SelectIDEModal";
import { getExperimentsClient } from "../experiments/client";

export interface WorkspacesProps {}

Expand All @@ -36,6 +37,7 @@ export default function () {
const [inactiveWorkspaces, setInactiveWorkspaces] = useState<WorkspaceInfo[]>([]);
const [workspaceModel, setWorkspaceModel] = useState<WorkspaceModel>();
const { setIsStartWorkspaceModalVisible } = useContext(StartWorkspaceModalContext);
const [isExperimentEnabled, setExperiment] = useState<boolean>(false);

useEffect(() => {
(async () => {
Expand All @@ -45,13 +47,22 @@ export default function () {
}, [teams, location]);

const isOnboardingUser = user && User.isOnboardingUser(user);
useEffect(() => {
(async () => {
if (teams && teams.length > 0) {
const isEnabled = await getExperimentsClient().getValueAsync("isMyFirstFeatureEnabled", false, {
teamName: teams[0]?.name,
});
setExperiment(isEnabled);
}
})();
}, [teams]);
console.log("Is experiment enabled? ", isExperimentEnabled);

return (
<>
<Header title="Workspaces" subtitle="Manage recent and stopped workspaces." />

{isOnboardingUser && <SelectIDEModal />}

{workspaceModel?.initialized &&
(activeWorkspaces.length > 0 || inactiveWorkspaces.length > 0 || workspaceModel.searchTerm ? (
<>
Expand Down
1 change: 1 addition & 0 deletions components/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
"base-64": "^1.0.0",
"bitbucket": "^2.7.0",
"body-parser": "^1.19.2",
"configcat-node": "^6.7.1",
"cookie": "^0.4.2",
"cookie-parser": "^1.4.6",
"cors": "^2.8.4",
Expand Down
22 changes: 22 additions & 0 deletions components/server/src/experiments.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/**
* Copyright (c) 2022 Gitpod GmbH. All rights reserved.
* Licensed under the GNU Affero General Public License (AGPL).
* See License-AGPL.txt in the project root for license information.
*/

import * as configcat from "configcat-node";
import { IConfigCatClient } from "configcat-common/lib/ConfigCatClient";

let logger = configcat.createConsoleLogger(3); // Setting log level to 3 (Info)
let client: IConfigCatClient | undefined;

export function getExperimentsClient(): IConfigCatClient {
if (client === undefined) {
client = configcat.createClient("WBLaCPtkjkqKHlHedziE9g/LEAOCNkbuUKiqUZAcVg7dw", {
// <-- This is the actual SDK Key for your Test environment
logger: logger,
});
}

return client;
}
15 changes: 15 additions & 0 deletions components/server/src/workspace/gitpod-server-impl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,7 @@ import { Deferred } from "@gitpod/gitpod-protocol/lib/util/deferred";
import { InstallationAdminTelemetryDataProvider } from "../installation-admin/telemetry-data-provider";
import { LicenseEvaluator } from "@gitpod/licensor/lib";
import { Feature } from "@gitpod/licensor/lib/api";
import { getExperimentsClient } from "../experiments";

// shortcut
export const traceWI = (ctx: TraceContext, wi: Omit<LogContext, "userId">) => TraceContext.setOWI(ctx, wi); // userId is already taken care of in WebsocketConnectionManager
Expand Down Expand Up @@ -2135,6 +2136,20 @@ export class GitpodServerImpl implements GitpodServerWithTracing, Disposable {
// Anyone who can read a team's information (i.e. any team member) can create a new project.
await this.guardTeamOperation(params.teamId, "get");
}

const isFeatureEnabled = await getExperimentsClient().getValueAsync("isMyFirstFeatureEnabled", false, {
identifier: user.id,
custom: {
project_name: params.name,
},
});
if (isFeatureEnabled) {
throw new ResponseError(
ErrorCodes.NOT_FOUND,
`Feature is disabled for this user or project - sample usage of experiements`,
);
}

const project = this.projectsService.createProject(params, user);
this.analytics.track({
userId: user.id,
Expand Down
26 changes: 26 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -5813,6 +5813,27 @@ concurrently@^6.2.1:
tree-kill "^1.2.2"
yargs "^16.2.0"

configcat-common@^4.6.1, configcat-common@^4.6.2:
version "4.6.2"
resolved "https://registry.yarnpkg.com/configcat-common/-/configcat-common-4.6.2.tgz#ef37114cf77c10378e686078bc45903508e0ed49"
integrity sha512-o7qp5xb3K9w3tL0dK0g2/IwzOym4SOcdl+Hgh7d1125fKamDk8Jg6nBb+jEkA0qs0msYI+kpcL7pEsihYUhEDg==

configcat-js@^5.7.2:
version "5.7.2"
resolved "https://registry.yarnpkg.com/configcat-js/-/configcat-js-5.7.2.tgz#2466269f941c8564c0d2670ffbc74dc0657f1450"
integrity sha512-Pvryi3y1z1ZyhId5fGv6Weel6YU6EuTHHYdfY1SOaVSvNeXNU9HwLpzMUCwdINtSXyxtHd0xUMumRUje2h7/Ng==
dependencies:
configcat-common "^4.6.2"

configcat-node@^6.7.1:
version "6.7.1"
resolved "https://registry.yarnpkg.com/configcat-node/-/configcat-node-6.7.1.tgz#87c6be569d646575c969d00966a97416a4b6a4fa"
integrity sha512-+tdKZkrWo3JdRezU90daly9LmL2efCDTnjHlKMUpwtVjrNjPVXggrydrgB5QUKmJUspWUd9bFSXS3jQgoGpB4g==
dependencies:
configcat-common "^4.6.1"
got "^9.6.0"
tunnel "0.0.6"

configstore@^5.0.0, configstore@^5.0.1:
version "5.0.1"
resolved "https://registry.yarnpkg.com/configstore/-/configstore-5.0.1.tgz#d365021b5df4b98cdd187d6a3b0e3f6a7cc5ed96"
Expand Down Expand Up @@ -17078,6 +17099,11 @@ tunnel-agent@^0.6.0:
dependencies:
safe-buffer "^5.0.1"

[email protected]:
version "0.0.6"
resolved "https://registry.yarnpkg.com/tunnel/-/tunnel-0.0.6.tgz#72f1314b34a5b192db012324df2cc587ca47f92c"
integrity sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==

tweetnacl@^0.14.3, tweetnacl@~0.14.0:
version "0.14.5"
resolved "https://registry.yarnpkg.com/tweetnacl/-/tweetnacl-0.14.5.tgz#5ae68177f192d4456269d108afa93ff8743f4f64"
Expand Down